
产品思维(一):架构设计 — 从单体到自治 Agent
我的架构思维如何从一个 Next.js 应用演进到分布式自治 Agent 系统——以及沿途涌现的模式。
系统的形状#
每一种架构都是一场被冻结的争论。它记录的是你提交代码那一刻对问题的信念。回顾过去十八个月我构建的四个系统——一个营销内容平台(~7 万行 TypeScript)、一个零依赖技能路由引擎、一个自主研究 Agent(~31.5 万行 Python)、以及一个多模型编码编排器——我能清楚地追溯自己架构直觉的轨迹。并非总是向前,有时是横向偏移。但有一条主线:从"把一切放在一个进程里"到"让 Agent 自己治理自己"。

这不是分布式系统教程,而是对"我为什么做了那些结构选择、什么东西崩了、哪些原则在生产中活了下来"的反思。四个系统横跨很大的复杂度范围——从单文件技能路由器到 7×24 运行的自主研究管线——但它们有一条共同的线索:凡是经得住时间检验的架构决策,背后都有一个具体的、可观察的故障模式;凡是过时的决策,背后都是一个被想象出来的故障模式。
第一幕:运转良好的单体 (AI4Marketing)#
AI4Marketing 起初只是一个周末原型:一个调用 Qwen 生成营销文案的 Next.js 应用。十八个月后,它有 32 个 API 路由目录(121 个独立 handler)、21 张数据库表、一条视频生产管线、一个短剧生成引擎、一套 GEO 优化系统,以及接入支付宝、微信支付、Stripe、PayPal 四家支付商的支付流程。生产 TypeScript 代码库约 7 万行。它仍然是 PM2 fork 模式下的单个 Node.js 进程——甚至不是 cluster 模式。一个进程,一条 JavaScript 线程,一个事件循环。
没有人计划做单体。我只是计划快速交付。架构是那种速度留下的残余物。
为什么它一直是单体#
AI4Marketing 本质上是一个带有长时间副作用的请求-响应应用。用户提交内容生成请求;系统验证、扣减配额、调用 LLM、返回结果。视频管线最长需要十五分钟,但它仍然由一个 HTTP 请求触发,并将进度写入同一个 PostgreSQL 数据库。没有服务间通信,因为根本没有服务——只有进程内的函数调用。
从来没有拆分的理由。“微服务会更清晰"的论点一次次败给"我能在一个终端里 grep 整个代码库”。视频管线挂了,我查 lib/video-pipeline-v2.ts(1684 行)。配额逻辑有误,我看 lib/quota-checker.ts(389 行)。支付 webhook 行为异常,handler 就在 app/api/webhook/alipay/route.ts。没有服务网格,没有消息队列,没有容器编排。一个进程、一个数据库、一个部署目标,和 pm2 restart ai4m。
数据库 schema 是以 User 为中心的星型模型。Post、VideoProject、DramaProject、Order、Subscription 向外辐射,每个关系都是直接外键——没有事件溯源,没有 CQRS,没有最终一致性。需要知道用户配额时,读一行。需要视频项目时,join 两张表。无聊的技术,但它有效。
星型 schema 还让配额强制执行保持简单:每一种资源消耗——内容生成、视频渲染、短剧制作、GEO 优化——都归结为读取 user.quotaUsed 和 user.quotaLimit,检查两者之差是否大于操作成本。一张表、两列、一次比较。复杂性在条件更新里,不在数据模型里。
让单体可维护的模式#
AI4Marketing 约 7 万行 TypeScript 按四个层级组织:app/api/(路由 handler)、lib/(领域逻辑)、components/(React UI)、prisma/(schema 和迁移)。每一层都有严格的"不向上导入"规则:领域逻辑永远不导入路由 handler,UI 只通过路由 handler 访问领域逻辑。这不是工具强制的——是纪律和代码审查强制的。十八个月后,没有违规悄悄溜进来。架构是稳定的。
让 121 条路由没有变成不可维护意面的,是 handler 层面的约定。每一条路由都遵循完全相同的分层模式:
认证 → 校验 (Zod) → 限流 → 配额检查 → 业务逻辑 → 失败退款
这不是框架功能,是我一遍遍读自己代码直到模式成为肌肉记忆后形成的约定。withMetrics 高阶函数包裹每个路由 handler,无需 opt-in 就记录 http_request_duration_seconds 和 http_requests_total。路由标签显式指定——从不从动态路径参数自动推导——以防止指标存储中的基数爆炸。
配额系统解决了一个真实的并发问题。朴素方法——读余额、检查、扣减——有教科书级的 TOCTOU 竞态:两个并发请求都读到"余额 = 1",都通过检查,都扣款。修复方案让检查和扣减原子化:
| |
这个 WHERE 子句消除了竞态。两个并发请求不可能同时成功——在行级别只有一个能匹配条件。PostgreSQL 的行级锁保证这一点。三行代码修复一个曾让生产系统损失数百万美元的问题。
限流器使用十个来自真实生产流量观察的预设:
| |
这些是内存滑动窗口,不依赖 Redis。存储通过定期清理限界:每五分钟清理一次过期条目,超过 10,000 条时按时间驱逐最旧的。对单进程应用这足够了,因为内存永远不会迁移。
STATUS_POLL 设到 120/分钟有具体原因:早期用户会同时打开多个标签页,每个标签页都以最高频率轮询进度,产生足够的负载来拖慢实际的视频处理。上限高到足以支持流畅的进度条,又低到足以防止意外自 DDoS。这个校准来自生产观察,不是提前推算的。
优雅关停 handler 在 SIGTERM 时注册,设置 shuttingDown 标志拒绝新请求,然后每两秒轮询 activePipelines 集合。所有管线在十分钟内排空则干净退出,否则强制退出。PM2 的 kill_timeout 与之对齐。这意味着部署不会杀死进行中的视频渲染——它们完成后进程才回收。AI4Marketing 的视频渲染需要 8-15 分钟;没有优雅关停,每次 pm2 restart 都会在数据库里留下一个状态为 processing 的孤儿任务,需要手动清理。
隐藏的复杂性:API Key 轮转#
单体里做着真正分布式系统工作的地方是 lib/api-key-manager.ts(483 行)。AI4Marketing 每天发起数百次 DashScope 调用,跨越分布在 CN 和 INTL 两个区域的十六个 key。轮转逻辑对三类故障模式分别处理:收到 429 立即切换到下一个 key 不做退避;其他错误指数退避重试;持续失败则将 key 标记为不健康并跳过三十分钟。key 还按区域分区——CN key 在 INTL 端点快速失败,而不是浪费重试预算。
key 耗尽时(所有 key 都不健康或都在限流),管理器返回结构化错误,让调用方向用户显示"服务暂时不可用,请稍后重试",而不是崩溃或返回神秘的提供商错误。在总资源耗尽下的优雅降级是一个设计选择,不是事后补丁——后期添加它需要理解所有调用点。
这是单体内部的微型分布式系统思维。进程边界固定;进程内的路由逻辑出乎意料地复杂。
视频管线(lib/video-pipeline-v2.ts,1684 行)是代码库里最复杂的单个文件。它追踪六个阶段——QUEUED、PREPARING、GENERATING、COMPOSITING、ENCODING、DONE——每个阶段完成后都把显式状态写入数据库。进程崩溃后,下次运行从数据库读取阶段并跳过已完成的步骤。这是单体内部手写的 FSM——同样的模式后来在 Research Agent 里变成了正式的 FSM。
这联结了 AI4Marketing 和后续系统的关键洞察:显式阶段追踪是管线和函数调用的本质区别。函数调用要么完成要么不完成。管线有在进程死后仍然存在的中间状态,这些状态必须是持久的、无歧义的。
AI4Marketing 的架构并不是在开辟新天地。它有趣的地方在于它展示了纪律性约定在单个进程内能带你走多远。我碰到的限制不是代码复杂度,而是资源隔离。当一个用户的视频渲染吃掉所有可用内存时,它会降低所有其他用户的请求质量。在单体里,无法给视频管线分配独立的内存预算,除非给它独立的进程。到了那一刻,单体就该退场了。
第二幕:零依赖作为架构 (DaaS)#
DaaS 是一个营销文案技能路由器。它接收任务描述,在技能定义库中匹配,执行正确的工具链,返回结构化文案。运行时路径中零 npm 依赖——不是"极少依赖",是零。
这个约束是刻意的。AI4Marketing 的 package.json 有 47 个直接依赖,完整的 node_modules 目录占 1.2 GB。每个依赖都是供应链风险、升级负担和运行时意外的潜在来源。DaaS 是一个反向实验:从零开始构建会发生什么?
结果是更多样板代码,以及一个极简的故障面。DaaS 调用的每个函数都是我写的代码。出了问题,堆栈跟踪结束在我的文件里,不是我从未读过的某个传递依赖里。
AI4Marketing node_modules 的 1.2 GB 不只是磁盘空间——是运营表面积。每个包都可能有自己的 bug、安全漏洞、小版本中的破坏性变更,以及对运行时环境的隐式假设。我遇过所有四类:一个 Prisma 小版本改变了它处理无时区日期的方式,一个 Next.js 补丁改变了它传递 header 到路由 handler 的方式,一个 sharp 图像处理库假设宿主上有特定版本的 libvips。每次事故都要耗费几个小时。DaaS 有零次此类事故,因为它没有可以变化的第三方代码。
状态作为原子文件#
DaaS 通过一个小管线追踪 ingest job 状态:queued → processing → done → failed。状态持久化为原子 JSON 文件——Linux 上同一文件系统的 mv 是原子的,所以 job 要么处于旧状态要么处于新状态,从不处于损坏的中间状态。路由缓存用 mtime 失效:如果技能定义文件变了,下次请求时缓存的编译结果就是陈旧的。
这是最简单的、同时也是正确的持久状态。没有 ORM,没有迁移运行器,没有 schema 文件。“数据库"是一个 JSON 文件目录。
mtime 失效模式值得多说一句,因为它用零基础设施解决了缓存失效问题。经典方案是 TTL 缓存:条目超过 N 秒就重新获取。TTL 缓存有固有的权衡——短 TTL 意味着频繁重计算,长 TTL 意味着陈旧数据。mtime 失效没有权衡:缓存和底层文件始终完全同步。唯一要求是对源文件的写入是原子的,而同一文件系统上的 mv 保证了这一点。DaaS 的路由缓存在每次修改后恰好重编译一次,零延迟,零陈旧。TTL 缓存无法做到这一点。
上帝文件问题#
DaaS 最初有一个 server.py 处理路由、解析、执行、缓存、日志和健康检查。到大约 3500 行时它变得真正危险——改路由逻辑需要理解执行层,改缓存需要同时理解两者。耦合是真实的,测试面是不透明的。
修复是机械的:把每个关注点提取到自己的模块,强制跨模块导入只通过单一公共接口,让 server.py 成为纯 HTTP 分发器。结果是 17 个提取模块,server.py 降到约 1200 行路由分发和编排。没有新功能,只是结构。
上帝文件问题是对称的:零依赖和重依赖代码库都会积累它。在重依赖项目里,上帝文件往往藏在框架魔法背后——框架把各种不相关的关注点缝合在一起,你从不注意到耦合,直到你想改一块然后发现它牵连着另外六块。在零依赖项目里,耦合从第一天起就赤裸裸可见,这让它更容易早期处理,但也更容易忽视——因为没有 linter 报警。纪律必须来自你自己。
教训:零依赖不等于零结构。结构必须是自我施加的,恰恰因为没有框架来施加它。而提取必须在上帝文件纠缠太深而无法安全重构之前发生。
DaaS 还教会了我,零依赖约束是功能蔓延的有效过滤器。添加一个功能需要引入一个库时,我必须认真论证。大多数情况下,功能要么用 50 行普通代码实现,要么被砍掉。这个约束是有价值的摩擦。
DaaS 和 AI4Marketing 代表两个极端:一个有一切,一个什么都没有。Research Agent 需要两者之间的某种东西——足够的结构来管理真正复杂的多 Agent 工作流,但没有为请求-响应系统设计的完整应用框架的重量。中间路径是 FSM。
第三幕:状态机作为控制平面 (Research Agent)#
Research Agent 是我的架构发生最大概念跃迁的地方。它完全自主:从 arXiv 读论文,在 SQLite 中构建知识图谱(65,000+ 节点、197,000+ 边),生成研究想法,通过三轮对抗辩论筛选,设计实验,派发执行到 worker 节点,运行统计分析,撰写论文。它在一台 4 vCPU、7.5 GB 主服务器上 7×24 无人干预运行,配备两台 128 GB 计算 worker。Python 代码库约 31.5 万行,分布在大约 200 个源文件中,经过十个月的自主运行和增量修补不断生长。
代码库的规模是架构的直接结果。coordinator 单文件(coordinator.py)就有 1264 行。supervisor_lib/heal_rules.py 是 2279 行。framework/dispatch_decision.py 是 658 行。这些文件大不是因为代码杂乱,而是因为领域真的需要管理大量不同的状态和转移。一个 40 条规则的自愈文件是系统能在无人干预下处理 40 种不同故障模式的代价。
让这一切成为可能的核心洞察:为每一个实体生命周期建立显式有限状态机,在数据层强制执行。
为什么用 FSM#
引入 FSM 之前,coordinator 用一个 15 级 if/elif 链来决定下一步做什么。它检查标志:“如果有一个状态为 approved 且没有 protocol.json 的 idea,派发 experiment_designer。“三个状态时能用。到了十二个状态加上失败路径、重试和基础设施错误,它变成了组合爆炸。真实 bug 出现了:一个实验永远停在 executed 状态,因为派发 statistician 的条件检查 analysis.json 不存在——但分析途中的崩溃留下了一个残缺文件,满足了存在检查却不包含有效结果。
用标志检查的深层问题在于它把状态隐式编码在多个字段的组合里。“实验完成了"意味着"status 是 executed 且 analysis.json 存在 且文件可解析 且文件包含 decision 字段”。这是四个条件,每个都可以独立失败,任何一个失败都让系统处于没有单一检查能发现的未定义状态。FSM 把这压缩为一个值:protocol.status == 'analyzed'。如果是 analyzed,说明所有四个条件在转移时都已经验证过了。如果不是,你能从历史日志知道哪个步骤在什么时候失败了。
FSM(framework/pipeline_state.py,222 行)通过让转移显式且穷举来消除了整个这类 bug:
Idea 生命周期:
proposed → approved → experiment_designed → experiment_completed
| | | |
| | └─ negative_result └─ paper_written
| └─ killed / failed |
└─ merged / subsumed paper_reviewed
Protocol 生命周期:
designed → running → executed → analyzed
| | | |
| └─ failed └─ failed └─ pending_failure_analysis
└─ needs_redesign └─ needs_redesign / needs_rerun
└─ abandoned / dataset_failed └─ closed_negative
每次 transition(entity, from_state, to_state) 调用验证转移是否合法,带时间戳追加到 .history.jsonl,非法移动时抛出 InvalidTransition。你不能把 idea 从 proposed 直接移到 paper_written——FSM 强制它必须经过设计、执行和分析。FSM 替换了散布在不同文件里的 35+ 个直接 proto['status'] = ... 赋值点。光是这一项就值得这次重构。
FSM 也是文档。pipeline_state.py 里的 IDEA_TRANSITIONS 和 PROTOCOL_TRANSITIONS 字典是系统能做什么的权威规格说明。想知道 idea 能不能从 approved 直接跳到 paper_written?看 IDEA_TRANSITIONS['approved']——只有 experiment_designed、killed 和 failed 是可达的。这不是写在某个 README 里的,它在运行时被强制执行,而这个强制执行本身就是文档。
FSM 真正替换的是什么#
在 framework/pipeline_state.py 存在之前,coordinator 有 35+ 个直接 proto['status'] = '某个字符串' 赋值点散落在不同文件中。没有验证,没有审计跟踪,没有合法转移的强制。一个实验理论上可以从 designed 直接跳到 analyzed,如果某个代码路径的 bug 意外调用了错误的更新函数。
FSM 重构花了两天:写转移表,把所有 35 个赋值点替换为 transition() 调用,让审计日志填充。发现的第一件事是三个 bug:一个 idea 静默卡在 proposed 已经九天,因为合并检查读的是 approved 而不是 proposed;一个 protocol 有两个同时存在的状态值,因为两个进程都写了它;一个失败分析被应用了两次,因为转移守护有竞态条件。
FSM 没有阻止这些 bug——它们已经发生了。但它让它们变得可见。审计日志显示了每个非法状态发生的确切时间、导致它的代码路径,以及哪些转移被尝试过。这是显式状态的真正价值:不是它能阻止所有错误,而是让错误可观察、可诊断。
派发决策层#
Boss Agent 是一个 ReAct LLM 编排器,决定下一步派发什么。但它不是从头决定——它评估来自 framework/dispatch_decision.py(658 行)的九个结构化谓词:
| |
每个谓词(如 should_dispatch_experimenter(experiment_id))读取 FSM 状态,检查必要前置条件(protocol 存在、没有活跃锁、slot 可用),返回结构化决策。Boss 读取所有九个谓词的输出,按优先级排序,派发最高的那个。
优先级编码了从实测瓶颈推导的隐式调度策略:
| |
这些数字在一个每 30 秒热重载的 JSON 文件里。不重启任何进程就能重新排列整个系统的行为优先级。更重要的是,某事未能派发时,reason 字段告诉你确切原因:“Protocol 尚未设计"或"所有 experimenter slot 已占用(3/3)“或"目标被 PID 12847 锁定”。没有谜团,每一个非行为都有可审计的解释。
这个特性在实际调试中救过我很多次。有一次 Research Agent 停滞了十二小时——四个 approved ideas,零派发。用优先队列,我会看到一个空队列,没有更多信息。用谓词,我读了九个 reason 字段:全部说"所有 experimenter slot 已占用(3/3)"。三个实验被不再存在的 PID 锁住了——worker 死了没有释放锁。锁收割器没有触发,因为锁时间戳是 3.5 小时前,刚好低于 4 小时强制移除阈值。reason 字段在 30 秒内给了我诊断结果。把阈值改为 2 小时又花了 30 秒。总调试时间不到两分钟。优先队列会让我毫无线索。
Fleet:通过 HTTP 轮询的分布式执行#
主服务器只有 7.5 GB 内存。运行 LLM 驱动的实验——加载大数据集,发起数百次 API 调用——会立即 OOM。fleet 架构用极简方案解决这个问题:worker 每 5 秒轮询主服务器索取命令,在本地执行,然后 POST 结果回来。worker 还每 60 秒 POST 心跳附带系统指标(负载、内存、磁盘、运行中 agent 数)。独立的同步循环每 60 秒推送生成的产物——论文、KG patch、实验数据。
就这样。HTTP 轮询。没有 WebSocket,没有 gRPC 流,没有消息代理。worker 可以离线再回来——它只是恢复轮询。主服务器通过心跳新鲜度追踪健康状态:超过 600 秒未有心跳,worker 被标记为不可选,其已派发任务最终被孤儿派发回收器处理。整个 fleet 服务器是 680 行 Python,四个 HTTP 端点。任何派发失败都可以用 curl 调试。
四个端点是:/heartbeat(POST,worker 汇报状态)、/commands(GET,worker 轮询任务)、/result(POST,worker 返回结果)、/dispatch(POST,coordinator 给特定 worker 排队任务)。这就是完整的 API 表面。加一个新 worker 的步骤是:克隆代码,设置 fleet 服务器 URL 和密钥,运行。worker 通过第一次心跳完成自注册。
为什么不用 Celery 或 Temporal?因为那些系统本身就是复杂的分布式系统,需要 Redis 或 RabbitMQ、需要学习的故障模式、以及需要监控的运营表面。我的 fleet 协议只依赖 Python 标准库。它的故障模式精确地和我设计的一样复杂。
两台 128 GB 计算 worker 的存在是因为 LLM 实验有时需要在内存里加载 40-60 GB 的数据集。主服务器做不到。worker 能做到。fleet 协议是 coordinator 决策(7.5 GB,状态密集)和 worker 原始执行能力(128 GB,无状态)之间的窄桥。这种不对称——一个小而聪明的大脑协调大而愚笨的肌肉——是我在分布式系统中反复重新发现的模式。
自愈:四十条自动恢复规则#
在一台 7.5 GB 内存的机器上跑六个 systemd 服务——coordinator、pipeline、dashboard、supervisor、kg-merger、dingtalk-listener——OOM kill 不是理论上的,它每周都会发生。架构不试图防止 OOM,它假设 OOM 会发生并为恢复而设计。
OOM 优先级分层。 pipeline 得到 OOMScoreAdjust=+500(Linux 优先牺牲它)。coordinator 得到 -500(受保护)。内存压力来袭时,先杀 pipeline 再杀 coordinator。pipeline 的所有状态都在磁盘——它重启后从中断处恢复。
内部内存看门狗。 coordinator 和 pipeline 都有后台线程,每 2-3 分钟读一次 /proc/self/status。coordinator 在 swap 超过 1500 MB 时触发激进 GC,RSS+swap 超过 2400 MB 时强制退出。pipeline 在 1900 MB 时强制退出。强制退出是刻意的:从已知状态重启比带着退化内存苟活要廉价。
Supervisor 调和。 supervisor_lib/heal_rules.py(2279 行)包含 40+ 条规则,每分钟触发。规则 1:analysis.json 存在但状态仍是 executed,强制转移到 analyzed。规则 2:failure_analysis.json 存在并有决策,应用该决策。规则 3:redesign 次数超过 5,杀掉 idea(防止无限重设计循环的后备)。规则 4:needs_redesign 卡住超过 24 小时且有 5+ 次派发尝试,设为 abandoned。
这 40 条规则里没有任何聪明的代码。每一条都是我遇到过的一个具体故障模式,写下来,自动化掉。规则 1 存在是因为我曾有一个实验卡在 executed 六天。规则 3 存在是因为一个 idea 曾重设计了十七次,然后我才意识到 protocol 从根本上和可用数据集不兼容。规则 4 存在是因为 needs_redesign 如果 designer 在写完新 protocol 之前一直被杀掉,可能成为一个 sink 状态。每条规则都有注释说明是哪个 bug 催生了它。
基础设施错误分类。 如果 failure_analysis 发现根因包含"timeout”、“429"或"404”,将 protocol 重置为 designed 以重试,而非触发 redesign。瞬态基础设施故障不应浪费一次 redesign 周期。
陈旧锁收割。 锁使用 fcntl.flock(),内部写入 PID + 时间戳。supervisor 检查 PID 是否存活;死 PID 的锁被移除。超过 4 小时 TTL 的锁无论如何被强制移除。
invariant checker(framework/invariant_checker.py)在每个 boss 周期后运行,强制全局一致性:状态/产物对齐、无重复派发、无孤立锁。违规以 ERROR 级别记录并持久化到 data/invariant_violations.json。即使 bug 引入了不一致,下一个 boss 周期也会检测并标记它。
自愈的关键区分是被动修复(检测到损坏状态,修复它)和主动修复(检测到将导致损坏状态的条件,提前修复)。invariant checker 是被动的:每次 boss 周期后跑,修复它发现的问题。内存看门狗是主动的:在 OOM 发生之前检测内存压力,干净退出而不是被混乱地杀死。锁收割器两者都是:清理死锁(被动)并检查心跳新鲜度,在 worker 积累更多任务之前把它标记为不可选(主动)。
第四幕:DAG 分解复杂任务 (Elevator)#
Research Agent 为一组固定的 agent 类型解决了协调问题,它们有固定的生命周期。每个 idea 经历同样的 FSM;每个实验经历同样的 protocol 生命周期。Elevator 面对的挑战不同:任务结构是动态的。一个编码目标有和调试目标不同的子目标图,后者又和重构目标不同。你无法预先定义 FSM,因为在看到目标之前你不知道会有哪些状态。DAG 取代 FSM 成为协调原语。
Elevator 通过结构化管线编排多个中国 LLM——Qwen、DeepSeek、Kimi、GLM、MiniMax:目标分解为子目标 DAG、并行批次执行、跨模型验证、经验学习。所有模型通过 DashScope 的统一 OpenAI 兼容 API 访问——模型切换是改一个字符串。
DAG 作为执行计划#
当用户提交一个目标——“开发一个微信小游戏仙侠放置 RPG”——planner(planner.py,474 行)将其转换为结构化 JSON DAG。生成计划前,它注入四个上下文来源:全局 lessons(过去要避免的失败)、skills(先前任务的成功计划模板)、workspace 状态(当前文件)、project memory(跨里程碑决策)。一个真实项目的 DAG 看起来像:
m1: Canvas 框架 + 游戏循环 (无依赖)
m2: 角色系统 + 属性 (无依赖)
m3: 配置驱动架构 (依赖: m1, m2)
m4: 战斗系统 (依赖: m3)
m5: 背包 + 装备 (依赖: m3)
m6: 修炼进阶 (依赖: m4)
m7: NPC + 对话系统 (依赖: m3)
m8: 地图 + 探索 (依赖: m7)
m9: UI 打磨 (依赖: m5, m6)
m10: 美术资源 (依赖: m9)
m11: 音效 (依赖: m10)
m12: 存档/读档系统 (依赖: m8)
m13: 性能优化 (依赖: m11, m12)
m14: 最终 QA + 打包 (依赖: m13)
runtime 通过拓扑批次执行处理这个 DAG:m1 和 m2 并行(无依赖),然后 m3 等待两者,然后 m4/m5/m7 并行(都只依赖 m3),依此类推。ThreadPoolExecutor 管理并行执行。挂钟时间等于关键路径,不是所有里程碑时间之和。
DAG 验证不是平凡的,LLM 有时产出无效计划。planner 执行环检测、悬空引用移除和 ID 唯一性强制,然后才接受计划。环检测比听起来重要得多——早期 Elevator 计划有时有循环依赖(“m3 依赖 m5 依赖 m3”),因为 LLM 在思考逻辑关系而不是严格的构建顺序。拓扑排序在循环图上会死循环;planner 在排序前检测到环并强制重新规划。这是 LLM 输出验证中少数有正式正确性标准的地方:DAG 要么无环要么有环,不需要模糊评分。
可选的 critique 步骤把计划发给跨家族模型审查;发现严重问题时,revise_plan() 重写(受 MAX_CRITIQUE_ROUNDS 限制以防无限修改)。
跨家族验证:反阿谀模式#
Elevator 最有后果的架构决策是:没有任何模型评估自己的输出。执行管线在结构上强制这一点:
执行器 (qwen3.6-plus) 产出代码
│
▼
审查者 (deepseek-v4-pro) 检查 bug、边界情况、测试覆盖缺口
│
├─ 发现问题 → 注入反馈,回到执行器(最多 2 轮)
└─ 无问题 → 进入验证
│
▼
验证器 (qwen3.6-plus, 不同实例) 独立检查每个标准
│
├─ 低置信度 → 集成面板(3 个模型, 多数投票)
├─ 所有标准通过 → git commit, 标记完成
└─ 标准未通过 → 升级模型层级, 重试
为什么跨家族?同一家族的模型共享训练数据、架构偏见和故障模式。Qwen 写出有 bug 的代码时,另一个 Qwen 实例往往也抓不住 bug——它有相同的盲区。DeepSeek 的 MoE 架构处理信息的方式足够不同,能捕获 dense transformer 遗漏的错误。我通过经验发现了这一点:同家族审查对跨家族审查能捕获的 bug 有约 15% 的漏检率。
跨家族审查最常发现的 bug 是数组索引的差一错误和循环终止条件中不正确的边界处理——恰好是那种表面看起来合理的错误类型。一个和写出 bug 的模型训练在类似代码模式上的模型,倾向于把 bug 版本读成正确的。一个有不同架构归纳偏置的模型以新鲜的眼光读它,然后注意到边界条件是错的。代价是真实的:每个子目标一个额外的 LLM 调用。对 14 个里程碑的项目,这是 14 个额外调用。收益是在 bug 通过依赖子目标复合传播之前就捕获它们。
跨家族验证模式还揭示了我对模型选择的思考方式的变化。我曾把模型选择当成能力问题:“哪个模型最聪明?“验证架构把它重新框架为多样性问题:“哪些模型的故障模式最不同?“来自不同家族的较弱模型作为审查者,比来自同一家族的更强模型更有价值。DeepSeek v4-pro 不是我用过的最便宜或最快的模型,它是和 Qwen 架构差异最大的,这就是它作为跨家族审查者的原因。
渐进式升级:伪装成可靠性的成本优化#
三层升级路径的设计使 80% 的任务在最便宜的模型上完成:
Attempt 0: qwen3.6-plus ( 4 CNY/M 输入, 12 CNY/M 输出) — 处理大部分任务
Attempt 1: qwen3.6-max-preview (20 CNY/M 输入, 60 CNY/M 输出) — 更难的问题
Attempt 2: deepseek-v4-pro ( 4 CNY/M 输入, 16 CNY/M 输出) — 跨家族后备
attempt 0 到 attempt 1 的成本差异是 5 倍。模型能力对任务难度遵循幂律:大多数编码任务直截了当,便宜模型就能搞定。只有尾部——架构决策、微妙的并发 bug、复杂算法实现——需要贵模型。当同家族的贵模型也失败时,切换家族往往成功,因为失败模式是家族特定的。
复杂度在执行前估算:“fix”、“typo”、“rename"等关键词分类为 light(最多 12 轮);“architecture”、“refactor”、“full-stack"分类为 heavy(最多 35 轮)。这个预分类防止在琐碎任务上浪费贵模型预算。
绊线:行为异常检测#
执行器运行一个 agent 循环——LLM 反复选择和调用工具直到它宣布任务完成。这个循环以特征性的方式失败:无限读循环、重复的相同工具调用、或没跑测试就宣布完成。
绊线系统(tripwires.py,309 行)实时监控这些模式:
shallow_no_test:写了代码但从没跑 pytest/bash?阻止提交。repeat_loop:同一工具调用重复 N 次?警告,然后强制换个动作。no_progress_streak:多轮没有文件写入?注入反思提示。explore_overload:长时间阅读不产出代码?提示开始写。consecutive_read_only:N+ 轮连续只读?强制行动。
升级路径:warn → force_action → auto-submit(3 次强制后)。这防止了最昂贵的故障模式:一个 agent 循环烧掉 35 轮贵模型调用却一事无成。
绊线系统是 Elevator 版的 Research Agent 自愈规则。两个系统都面对同一个问题:自主 agent 可能在没有外部干预的情况下陷入无生产力的状态。Research Agent 的 agent 是长时间运行的后台进程;Elevator 的是短暂的前台循环。解决方案相同:显式的行为契约,实时监控,自动纠正行动从温和提示升级到硬停。
技能进化:积累的机构知识#
每次任务成功后,Elevator 提炼一个"技能”——从具体任务中抽象的计划模板。具体的文件路径、变量名和 API 端点被剥离;结构性模式被保留。技能遵循一个生命周期:
candidate (分数 0.5) → 验证关 → active (复用成功时分数增长)
│ │
└─ 被拒绝 → archived ├─ fail_streak ≥ 3: needs_review
├─ 分数 < 阈值: archived
└─ 闲置 180+ 天: archived
检索使用双策略:语义(embedding 余弦相似度)和关键词(查询关键词与技能上下文关键词的重叠)。高分技能排在低分技能上面。只有 active 技能对 planner 可见。
当技能库超过阈值时,相似技能(关键词集 Jaccard 系数 ≥ 0.3)被聚类并合并为聚合元技能。系统保留一般性有效的,忘记只有效过一次的。
技能系统的实际效果是:Elevator 第二次做仙侠放置 RPG 明显好于第一次。planner 提取第一个项目的技能模板作为起点。那次项目中有效的架构选择——配置驱动设计、战斗引擎和 UI 层的分离、存档/读档作为纯序列化关注点——已经被嵌入检索到的技能里。那次犯的错误——一个对角色系统内部数据结构有强依赖的战斗系统——以负向指导的形式注入到 lesson store 里。系统不需要人工整理就能积累机构记忆。
技能系统是 Research Agent 的知识图谱应用到了执行层。Research Agent 构建科学概念及其关系的图谱;Elevator 构建工程模式及其适用性分数的图谱。两者都在尝试解决同一个底层问题:系统如何避免重复犯同一个错误?Research Agent 用 FSM 审计历史来知道什么已经被尝试过。Elevator 用评分技能模板来知道什么有效过。形状不同,意图相同。

涌现的原则#
回顾这四个系统,某些模式反复出现,不论语言、框架或问题域。它们不是教科书里的最佳实践——它们是我犯了具体错误然后找到具体修复后得出的结论。教科书事后确认了它们,这是令人放心的,但不是来源。
1. 状态机不只适用于网络协议#
每一个管理多步骤工作流的系统都受益于显式 FSM。AI4Marketing 的视频管线追踪六个阶段。Research Agent 的 idea 生命周期有 15 个合法状态并带审计转移。Elevator 的 task/subgoal/milestone 层级是嵌套三级状态机。DaaS 通过管线阶段追踪 ingest job 状态。
替代方案——检查布尔标志和时间戳来推断状态——是我在所有四个系统中遇到的最大 bug 来源。“这个实验完成了吗?“应该由 protocol.status == 'analyzed' 回答,不是从文件存在性和修改时间推断。
一个具体的例子来自 Research Agent:FSM 之前,我花了一个下午调试一个卡住三天的实验。protocol 有一个 agent 写到一半崩溃后留下的 failure_analysis.json。coordinator 的"完成"检查是 os.path.exists('failure_analysis.json')——于是它标记实验完成了。但文件是残缺的,不包含可用的决策。FSM 修复:transition() 到 analyzed 不只要求文件存在,还要求文件能解析并包含有效值的 decision 字段。文件存在性不是状态。状态是状态。
2. 单体在生命周期边界出现之前一直是对的#
121 条路由的 AI4Marketing 仍然是单体,对其问题域仍然是正确的选择。当我需要多步骤、多 Agent 编排并带有独立故障域和独立资源预算时(Research Agent),单体变得不可持续。拐点不是代码行数、团队规模或吞吐量——是系统的不同部分是否需要独立的生命周期管理:启动、关停、崩溃恢复、资源分配、部署节奏。
Research Agent 的 coordinator 和 pipeline 是独立进程,因为它们需要不同的 OOM 优先级、不同的内存预算和不同的重启行为。这些需求在单个进程里是真正不相容的。如果 coordinator 能和 pipeline 一样自由地被杀掉,它们会是一个进程。它们不能。所以它们是两个。
AI4Marketing 有一个需要 15 分钟的视频管线和一个需要 20 毫秒的配额检查,两者都在同一个进程里。这没问题,因为它们有相同的生命周期要求——相同的数据库、相同的部署、相同的崩溃恢复行为、相同的内存预算。视频管线不需要成为独立服务,仅仅因为它需要更长时间。它需要成为独立服务只有当它需要独立的生命周期。目前它不需要。
3. 零依赖是可靠性工程的一种形式#
DaaS 运行数月,零依赖相关事故。没有 npm audit 告警,没有上游破坏性变更,没有别人的事件循环里的神秘崩溃。代价是更多样板代码。收益是每一个故障模式都是我自己的代码——可调试、可修复、可预测。
在时间压力下这最有价值。凌晨十一点有什么东西在生产中崩溃时,我最不想看到的是堆栈跟踪结束在 node_modules/某个库/dist/index.js:347。我想看到它结束在 lib/my-code.ts:89。前者需要在压力下理解别人的代码。后者需要理解我自己的代码,那是我写的,我理解。零依赖是调试时段的风险管理。
互补的教训是:零依赖并非总是对的。DaaS 足够简单,可以零依赖实现。AI4Marketing 不行——光是 Prisma ORM 就以需要数月才能正确重实现的方式处理 PostgreSQL 连接池、迁移运行和类型安全查询生成。问题不是"我该有依赖吗?“而是"哪些依赖我愿意完全信任,系统的哪些部分我想完全拥有?”
4. 派发谓词胜过优先队列#
Research Agent 和 Elevator 都使用声明式谓词(“这件事该发生吗?为什么或为什么不?")而非命令式调度(“下一步做这个”)。谓词是可组合的(添加新谓词无需修改现有的)、可测试的(单独单元测试每个谓词)、可调试的(某事未派发时读 reason 字段)。优先队列告诉你什么跑了。谓词告诉你什么为什么没跑。
5. 自愈对自主系统不是可选的#
Research Agent 每次无人干预连续运行数周。这只有因为它把故障视为正常运行条件才成为可能:内存看门狗、陈旧锁清理、状态/产物调和、每个周期后的不变量检查和 40+ 条自动修复规则。一个没有自愈能力的自主系统不过是一个静默崩溃并积累不可见状态腐化的系统。
heal_rules.py 里的 40 条规则里没有任何聪明代码。每一条都是一个我遇到、记录、然后自动化的具体故障模式。这正是实战经验积累的样子——不是通用的抽象原则,而是具体的:“在这个确切的条件下,做这个确切的事情”。
6. 跨家族验证防止模型幻觉洗白#
如果同一个模型家族既生产又评估输出,错误会复合而非抵消。模型有系统性盲区,无法在自己的输出里检测到。Elevator 的跨家族验证捕获了同家族审查遗漏的真实 bug。成本是审查环节双倍的 API 支出;收益是在 bug 通过依赖子目标复合传播之前就捕获它们。
7. 架构映射的是操作者,不只是问题#
回顾这四个系统,我注意到它们编码了具体的焦虑。AI4Marketing 痴迷于原子性——每个配额操作是原子条件更新,每个视频阶段是持久记录,每次关停是优雅的。Research Agent 痴迷于可观察性——每个状态转移都有审计,每个派发都有记录的原因,每个异常都写到不变量日志。DaaS 痴迷于表面积缩减——更少的依赖意味着更少需要理解的故障模式。Elevator 痴迷于错误复合——跨家族验证存在的确切原因是我亲眼看到 bug 如何通过依赖子目标级联。
这些不只是技术选择,是对"什么让我凌晨三点睡不着"这个问题的回答。配额竞态在早期 AI4Marketing 让我失眠。不透明的 FSM 状态在 Research Agent 让我失眠。未知的库行为在更早的项目里让我失眠。架构是我亲身经历过并承诺不再经历的故障的记录。
强制执行得最严格的模式,是我曾经违反过并为之付出代价的那些。AI4Marketing 限流器里的清理任务(驱逐超过 10,000 条的条目)存在是因为我曾看到限流器泄漏内存直到进程被杀。Research Agent 里 4 小时锁 TTL 存在是因为我曾有一个系统在 worker 崩溃后永远持有锁,再也无法恢复。Elevator 里的 shallow_no_test 绊线存在是因为我曾看着一个 LLM 写了 300 行看起来令人信服的代码,却从未执行过一个测试。架构是有更好命名的伤疤。
元模式#
如果我必须把以上一切压缩为一条架构原则:
让系统的状态显式、转移可审计、恢复自动化。其余一切都是优化。
AI4Marketing 通过原子条件更新让配额状态显式。DaaS 通过 mtime 失效缓存和原子文件持久化让路由状态显式。Research Agent 通过审计到历史文件的 FSM 转移让研究进展显式。Elevator 通过 DAG 批次和结构化验证检查点让任务执行显式。
这四个系统的演进不是真正的单体 → 微服务 → 分布式 Agent。更准确的描述是状态住在哪里以及它被多显式地管理这个维度上的演进:
- AI4Marketing:状态在 PostgreSQL 行里,通过条件 SQL 更新管理,通过直接外键 join 查询。
- DaaS:状态在 JSON 文件里,通过原子文件系统操作管理,通过 mtime 比较失效。
- Research Agent:状态在有
.history.jsonl审计日志的 JSON 文件里,通过拒绝非法转移的 FSM 管理。 - Elevator:状态在每节点有显式阶段追踪的 JSON DAG 里,通过强制拓扑排序的 coordinator 管理。
每一步都比上一步更显式。复杂度没有下降——Research Agent 比 AI4Marketing 复杂得多。但即使系统变得更复杂,状态在每一步都变得更容易推理。这是显式状态的真正回报:可以在不增加等比例不透明度的情况下添加能力。
显式状态还让"重新入职"变得更容易——某种意义上是重新入职你自己的系统,六个月后的自己。我离开 Research Agent 两周后回来,能在大约 90 秒内回答"系统现在在做什么”:读 coordinator 最后一次 boss 周期的日志,检查 data/invariant_violations.json,看活跃锁。没有 FSM 和审计日志,重建这个画面需要一个小时的日志考古。222 行 pipeline_state.py 每个月帮我省几个小时。
给我带来最少运维痛苦的系统,是那些我能通过读一个文件或查询一个端点就回答"现在正在发生什么?“的系统。在凌晨三点把我叫醒的系统,是那些状态隐式的——散落在日志文件中、从时间戳推断、或最糟糕的,编码在其他状态的缺失中(“如果这个文件不存在,那么我们一定在第 3 阶段”)。
架构不是在单体和微服务之间、SQL 和 NoSQL 之间、同步和异步之间做选择。这些都是跟随更根本选择的实现细节:状态住在哪里、如何变化、以及谁被允许改变它? 再加上附属问题:你的状态管理的故障模式是什么,系统能自动检测并从中恢复吗? 用清晰和纪律回答这些问题,系统的其余部分会自己设计自己。

本文是 产品思维 系列的第 1 篇,共 5 篇。 下一篇:第 2 篇 — 安全工程
产品思维 5 篇
- 01 产品思维(一):架构设计 — 从单体到自治 Agent 当前
- 02 产品思维(二):安全工程 — 不偏执的纵深防御
- 03 产品思维(三):用户体验与设计系统 — Token、暗色与双语
- 04 产品思维(四):自愈系统 — 教机器自己修自己
- 05 产品思维(五):抽象思维 — 从数学到系统