第二篇 · 一个问题自修复引擎是怎么搭建出来的

35 阅读46分钟

接着第一篇往下讲。这次单独拿出那条「bug 进 MR 出」的自动化链路。我们用一条具体的线上 bug 串起整篇——它怎么进系统、怎么被识别、怎么被派出去、怎么被修好、怎么知道下次不再犯。每经过一个环节,讲清楚那一层为什么这么设计。仅供大家参考。

1. 先讲一次差点把系统下线的事故

系统上线大约一个月时,出过一次凌晨出现 bug 的事。

事情本身不复杂。同一条线上 bug 被两个上游系统同时报上来——一份从用户工单进来、一份从前端的错误监控进来。这两份报告长得不太一样:工单的描述是用户写的中文,错误监控的描述是带 trace 的英文。但它们讲的是同一件事,根因是一行代码改错了类型。

系统当时的去重逻辑没识别出这是同一条 bug,把它当成两条独立任务派了出去。两个 agent 同时接,各自分析、各自写补丁、各自提了 MR。其中一个先合进 master,另一个的 CI 跑过去发现冲突,agent 自动 rebase 想解决,把前一个的修复给覆盖回去了一半。十分钟后线上重新开始报错。

从告警到回滚一个多小时。真正动手改的时间不到 10 分钟,剩下 50 分钟全花在「拼当时发生了什么」——查工单系统、查错误监控、查我们自己的派单日志、查两个 agent 的对话记录、对时间戳。每个系统的时间戳精度还不一样,对到秒就得猜。

第二天复盘,讨论到最后,发现其实核心的就是三件事。

  1. 这次出错的根因不在任何一个模块的代码里,在三个模块的边界假设互相不一致——去重那边以为派单会兜底并发,派单那边以为去重已经判过了,合 MR 那侧假设上游不会派重复任务。三方各自都没错,叠起来就漏了。
  2. 定位 50 分钟、修复 8 分钟,这个比例如果不调过来,再来几次半夜睡眠质量就保不了了。
  3. 之前所有讨论都在 agent 怎么修代码,没人讨论过 agent 外面那一层应该长什么样

那天之后,我的注意力挪到了一件事上:从「让 agent 跑起来」转到「设计 agent 外面那一层」。这一层后来叫问题自修复引擎。它自己不写代码、不诊断 bug、不调用 AI——它只负责一件事:让里面那群 AI agent 在生产环境里以可控、可审计、可恢复的方式运转。

这一篇要讲的就是这一层。我们用一条具体的线上 bug 把它的 8 个模块串起来——你跟着这条 bug 走完一遍,自然就能看懂整套系统在做什么、为什么这么做、哪些地方是被生产环境逼着改过的。

2. 建立心智模型

开始拆模块之前,先讲一个我们自己用的心智模型(这个比喻在后面每一节都会回来)。

你可以把这套系统想象成一座小工厂。AI agent 是工人,bug 是进厂的原料,修复完合并的 MR 是出厂的成品。中间需要的东西就一目了然:

  1. 一条传送带把原料运进来(这是采集层)
  2. 一道分拣工位决定这块料归哪个工人处理(这是分类与派单)
  3. 每个工位边上贴一张工艺路线卡记录这块料走到哪一步(这是状态机)
  4. 所有动作进车间广播登记在册(这是事件流)
  5. 车间主管手边永远有一个红色按钮可以叫停某条产线(这是人机接管)
  6. 每个月开一次复盘会,根据这个月数据调整工艺(这是慢回路演化)

这个类比可能不太直观,但在后面几个具体决策上都有真实映射体现。

比如「为什么状态信息要落数据库,不放 agent 进程内存里」——工艺路线卡不会贴在工人身上,工人下班了卡片得留在工位边。状态属于工厂,不属于工人。

比如「为什么所有动作都要登记下来」——工厂复盘从不问当事工人怎么想的,去翻车间监控就够了。AI 这边的"内部想法"是黑盒,外面这一层必须自己把可审计性建出来。

这个类比还定下了一条总原则:越是 AI 不确定的地方,外围的工程结构就越要确定

工厂里最不可预测的环节是工人——手艺有差异、状态有起伏、判断有出入。所以工厂用最严格的流程、最显式的工艺卡、最可追溯的事件流去兜住这种不确定。AI 系统是同样的形状。这条原则在后面几乎每个模块的设计里都会回来。

下面,我们让一条具体的线上 bug 走进这座工厂,跟着它走完一遍。

3. 第一站:bug 怎么进来

场景。早上 10:14:32,前端的错误监控抛出一条报错。同一秒,工单系统里也有一个客服同学手动建了一条工单,描述的是同一个用户反馈。再过 3 分钟,另一个内部的 alerting 脚本也扫到了这个异常。

问题来了:同一件事,三个上游报上来的样子完全不一样。错误监控给的是带 stack trace 的英文报错,工单是客服转述的中文描述,alerting 脚本给的是一行结构化的告警。每一类的字段不一样、频率不一样、可信度也不一样。

如果让下游每个模块各自去对接这三类上游,整套系统会很快陷在一张 N×M 的连接矩阵里——以后接第四个、第五个上游,每接一个都要在下游每个模块各加一段适配代码。这个负担很快就拖不动了。

所以采集层只做一件事:把外部世界的异构信号归一化成系统能消化的统一形状。它就像工厂的进料口——不管供应商是谁、原料装在什么样的箱子里,进了门都要打开重新包装成标准件,下游工位才能闭眼操作。

具体做法是——每个上游对应数据库里一行配置:API 地址、认证方式、字段映射规则、拉取频率、上次拉到哪里的游标。采集引擎按配置驱动,源码里不写任何特定上游的硬编码。新接一个上游不改代码,配置表里加一行就行。半年里我们接过两个新源,一个工单系统、一个客户私有 API,两次都是当天接完。

关键设计是字段映射用 JSONPath(一种描述"从 JSON 里取哪个字段"的小语法,类似从一封信里取"发件人 → 姓名"),不写死字段名。原因是上游 API 字段名经常变(工单系统升级一次能改三个字段名),如果硬编码,每次上游升级我们就要发版。JSONPath 配置在数据库里,上游变了运营改一行配置即可,不用动代码。

v1 这一层是按传统数据搬运(ETL)的套路写的——拉数据、转换字段、入库三步走。跑了两周才意识到不一样。传统数据搬运的目标是"保证能入库",AI 系统的归一化目标是"保证下游 AI 调用行为稳定" 。后者要求严苛得多——title 里多一个奇怪符号,下游分类器的置信度能差出 10 个百分点;某个字段偶尔是空的,下游 AI 提示词模板就崩。

所以后来这一层加了一堆看着多余的清洗规则:去 emoji、统一全半角、剥除上游加的装饰前缀(比如错误监控自动加的 [PROD][Frontend])、空字段统一替换成空字符串、超长 title 在 200 字符处截断。这些每一条都对应过下游 AI 调用抽风后的一次回查。

抽出来的判断是:AI 系统里 AI 含量最低的模块,往往杠杆最大。把 80% 工程注意力放在 AI 调用本身、20% 放在输入归一化是颠倒的。正确的比例至少是反过来。

**【设计骨架 · 采集层】**

**数据形状(上游配置):** 源标识、API 配置块(endpoint / auth / 分页规则)、字段映射(JSONPath → 内部字段名)、cron 表达式、checkpoint 游标、健康指标(last_success_at、连续失败次数)。

**归一化输出:** title、description、category、severity、tags、source_system、source_issue_id、source_raw_data(原始 payload 完整保留,方便回查)、extra_data(嵌套字段用点号路径表示)。

**关键接口:**

1.  `init_scheduler()`——系统启动时把每个 active 源注册成 cron job
1.  `run_collect(source_code)`——一次完整采集周期:读 checkpoint → 分页拉 → 字段映射 → 去重判定 → 入库 → 前进 checkpoint
1.  `toggle(source_code, active)`——运行时开关某个源,不用重启进程

**不变量:** 整个采集循环幂等——同一批数据重复拉不会重复入库(靠下游去重保证)。

原料归一化完了,接下来这条 bug 进入到下一站——去重判定。

4. 第二站:是不是早就有人报过

场景。三份报告进了系统,去重模块要回答一个问题:这是新 bug,还是已经存在的

这件事看上去简单,做起来全是坑。

三份报告的描述各不相同:错误监控的 title 是 "TypeError: Cannot read property 'amount' of undefined at OrderService.calc",工单的 title 是 "用户反馈结算页面金额显示异常",alerting 脚本的 title 是 "[Alert] order_service error_rate spike +180% in 5min"。同一个根因,三种长相。

如果识别不出这是同一件事,开头那个事故就会重演——三份独立的"bug"派给三个 agent,各改各的,最后撞车。所以去重不只是省资源,它是整个自动链路的一致性基础。它兜不住,下游所有"并行 agent 各自合 MR"的设计都建在沙子上。

怎么解?我们考虑过三个方案。

方案 A:只用指纹哈希精确匹配——便宜,可解释,毫秒级。但任何描述漂移都兜不住,三份报告的 title 不一样就识别不出来。

方案 B:直接上向量相似度(embedding) ——召回好(语义相似就能识别),但不可解释(出问题没法回答"为什么判成同一个")、每次几十到上百毫秒、依赖外部 AI 服务。

方案 C:分层短路——便宜可解释的判断在前,昂贵的判断在后,让大多数判断停在便宜那一层。

最后选 C。这个模式在架构里叫 cascade fallback(瀑布式兜底,热路径走快的、冷路径走慢的)。

用邮局分拣员做个比方比较好懂:

  1. 第一层(指纹精确) :先按邮编投递——同一上游同一时间窗多次推送,三段拼接的指纹完全一样,直接合并。
  2. 第二层(来源 ID 精确) :邮编不对再看门牌号——同一工单被改了 title 但 ID 没改,按 (上游系统, 上游 ID) 联合匹配能识别。
  3. 第三层(字符相似度模糊) :门牌号都不对,就看地址相似度——基于 PostgreSQL 的 trigram 索引算字符相似度,阈值 0.8,时间窗只看最近 180 天的活跃 bug。

三层串行,前一层命中后一层不跑。最后命中是哪一层会被记到事件表里——事后出问题想反查"这两条为啥被合并",能直接看到证据:是指纹一致、还是来源 ID 一致、还是字符相似度 0.85。

这一层故意没上 embedding。讨论过两次每次都被同一个理由劝退——AI 系统出事故最难受的不是错,是不知道为什么错。字符相似度的命中证据是字面的,事后追问"这两条为啥被合并"拿得出来;向量距离则解释不清,"距离 0.83 所以判同源"这种话讲不通也没法 debug。等哪天 pg_trgm 真的撞墙,再上 embedding 也会作为第四层在更后面——可解释的兜底永远在更靠前的位置。

开头那次事故就是 v1 漏判触发的。事故后 v2 做了两件事补救:

  1. 扩大了第二层匹配的字段集合——除了原来的 (上游系统, 上游 ID),又给每个上游注册了它特有的身份字段(错误监控的 culprit hash、APM 的 trace 指纹、内部 alerting 的 incident id)。这次扩面没改 dedup 模块代码,靠的是配置表里加几行——又一次把"代码硬编码"变成"配置驱动"。
  2. 在派单层加了硬性并发互斥——同一个 bug 簇同一时刻只能有一份活跃修复尝试,第二份从数据库层直接被拒绝。这件事下一节会展开。一道兜不住就两道。

抽出来的判断是:去重做到 100% 召回是不可能的,必然有漏网的。所以下游不能假设上游已经判过,每一层都要带自己的护栏。一次漏判在传统系统里是脏数据可以删,在 AI 系统里是永久污染——AI 调用不可重现,错了就错了,回不去。

**【设计骨架 · 去重判定】**

**三层 cascade 决策:**

1.  **L1 指纹**:SHA-256 of (源 + 归一化 title + category),归一化做"转小写 + 去标点"两件事
1.  **L2 来源 ID**:(source_system, source_issue_id) 联合查;可按源配置扩展身份字段
1.  **L3 字符相似度**:pg_trgm 相似度 > 0.8,180 天时间窗,过滤已 CLOSED / DUPLICATE / ARCHIVED

**关键接口:** `check_duplicate(fingerprint, source_system, source_issue_id, title) → (existing_issue, layer_label) | None`,layer_label ∈ {`fingerprint`, `source_id`, `pg_trgm`}。读-only,不改任何状态——计数 / 状态 / 事件落库由调用方在同一事务里完成。

**不变量:**

1.  更便宜更确定的判断永远在前——指纹优于来源 ID 优于模糊
1.  同一指纹只能有一行未归并的 issue,由唯一约束在数据库层强制
1.  已 CLOSED / DUPLICATE / ARCHIVED 的 issue 不参与 L3 匹配——防止新 bug 被合到三个月前已结案的簇上
1.  命中信息(哪一层、相似度、归并到哪条)随合并动作原子写入事件表

**扩展点:** L2 字段集合可配置;阈值可调;未来加 embedding 作为 L4 放在最后,不替换前面任何一层。

去重判完,三份报告被合成一条新的 issue,进入下一站——决定派给谁。

5. 第三站:派给谁来修

场景。这条 bug 进系统之后要派出去。但派给谁?我们手头有四个修复系统——前端 agent 池、后端 agent 池、基础设施 agent 池、客户端 agent 池,每个池子的能力边界还在变(今天新接一个、下周又退一个)。

这件事不能写死 if-else。如果分类规则和派单关系都焊在代码里,每次接入新系统都要发版,整套系统会越改越脆。

架构上这是一个老问题,叫 policy / mechanism 分离(决策依据和决策动作拆开——依据靠数据驱动可以演化,动作只负责按依据执行)。用医院的比方:分诊台护士先判断病人去哪个科(分类),挂号系统再按科室找一个有空的医生(路由)。两件事是分开的人和分开的流程。

我们具体落成两个组件。

  1. Classifier(分类器): 一次轻量 AI 调用,把 bug 候选按预定义的 category 树打标,每条标 1-3 个 category 和置信度("这是前端 bug,置信度 0.92")。category 树本身在数据库里,运营可以改。
  2. Router(路由器): category 到修复系统注册表里查派发——每个 category 可以注册多个修复系统按优先级排序,首选不可用自动 fallback 到次选。两步都不在代码里硬编码。

为什么要拆成两个?因为它们的可信度差异极大。分类器是 AI,每次结果可能不同、可能漏标、可能产生不存在的 category。路由器是确定性查表,输入确定输出确定。合在一个函数里,AI 的不确定性会污染整个派单路径,出问题时定位不知道从哪查起。拆开之后,AI 部分被压在一个独立的、可重放的边界里,事故定位至少能区分"是分类判错了"还是"是路由配错了"。

分类器这一步有个关键约束:每次 AI 调用要把 prompt 版本、模型版本、原始 response 全落事件表。这件事一开始没做,出过一次坑——某天分类突然抽风,所有 bug 全派给同一个系统,回查发现是 prompt 模板被人改了忘记 review,但 git diff 已经被后续 commit 覆盖,没法回溯改之前是什么样。补完审计落库之后,任何一次分类异常都能完整回放当时的输入输出和模板版本。

这是 AI 系统不同于传统业务系统的关键点——传统业务出问题重跑一次能复现,AI 系统重跑一次得到的可能是另一个结果,事故现场只有一次,必须在第一次就把所有上下文落下来。

路由这一侧也有个反直觉设计:category 到修复系统不是一对一,是一对多按优先级排序。v1 是一对一硬绑,某次首选池子全员升级,几十条 bug 全部卡 PENDING。v2 改成多目标 priority 队列,首选不可用自动 fallback——又是一次"单点假设"的代价。AI agent 池子的可用性远比传统服务低(受模型限流、上下文窗口、并发上限影响),不留 fallback 几乎一定会出事。

有意思的是,这一层是整个引擎里AI 含量最高的(每条 bug 都调一次 AI),但它在结构上反而被要求做得最像纯函数——输入 bug 候选、输出 category + 派给谁,中间所有 AI 调用都被一个可观测的边界包住。直觉会让人觉得"AI 强了所以可以放手",事实正好相反。

我们在组里把这条提炼成一句话:围绕 AI 调用的代码,必须比 AI 调用本身简单一个数量级

**【设计骨架 · 分类与路由】**

**Classifier 数据形状:** 输入是去重后的 bug 候选;输出是 1-3 个 (category, confidence) 对;每次调用落事件表,记录 prompt_version、model_version、temperature、raw_response。

**Router 数据形状(修复系统注册表):** system_code、category(可多对一)、priority、mode(push / pull)、callback_config、能力描述、限流参数。

**关键接口:**

1.  `classify(bug_candidate) → [(category, confidence), ...]` — 纯函数式,副作用只有事件落库
1.  `route(category) → [system_code, ...]` — 按 priority 排序的候选列表,调用方按序尝试

**不变量:**

1.  分类调用的所有上下文(prompt、model、temperature、response)必须落事件表,不能省
1.  category 必须在预定义树中,AI 产出不存在的 category 强制 fallback 到 unknown 走人工分类
1.  路由结果是有序候选,首选失败自动尝试次选,不丢任务

**扩展点:** category 树、路由表、priority 都在配置里;分类模型可热切;新增修复系统加一行配置即可。

分类完成、路由器选定了「派给后端 agent 池」。这条 bug 进入下一站——真正派出去。

6. 第四站:把任务派到外面

场景。10:14:35,派发模块要把这条 bug 推到后端 agent 池的某个具体实例上。听起来就是发个 HTTP 请求的事,但这一刻系统的"风险等级"突然升高了。

原因是——在这之前所有处理都在我们自己的数据库里,错了可以回滚;这一刻之后,bug 已经派给外部了,对方开始干活了,这个"派出去"的事实没法撤回

所以派发层在架构上的定位很特殊——它是整个系统第一个产生"对外副作用"的地方,是对外世界的统一边界。它做三件事,每件都是为了让边界外的不可控不要污染边界内的清晰。

  1. 第一件:协议适配。我们接的修复系统不全一样。有的是常驻在线的 agent 平台(可以接收回调),有的是间歇上线的——比如夜间停机的内部工具、容量受限的服务,只能它有空了来拉。如果只支持一种,能接的系统就少一半。

所以双协议都做。推(Push) 模式下,派发模块主动构造 HTTP 请求把 bug 推过去,对方返回一个 dispatch_id 写进我们的数据库。拉(Pull) 模式下,第三方主动调我们的一个统一接口,按它能处理的 category 拉一批走。每个修复系统注册时选自己的模式。

关键是不让协议差异渗透到内部状态机——无论是推还是拉,对状态机这一侧看到的都是同一种状态转移。v1 里我们一度让两种协议各自走不同的状态枚举,状态机文件膨胀到几百行没人读得懂;v2 收敛之后内部状态机只剩一套,协议差异完全压在派发层自己内部。

  1. 第二件:幂等。每个对外动作都要写得像在跟一个不可靠的远程服务交互——超时(5 秒)、重试(指数退避最多 3 次)、降级(推不通转 RETURNED 重新派)、幂等键(dispatch_id + idempotency_key 拼一起,保证重放不会真重派)。

幂等这件事在 AI 系统里特别重要。重复派一次 bug 可能导致两个 agent 撞车、可能让对方计费两次、可能触发对方限流——副作用是真金白银。

  1. 第三件:并发互斥。开头那次事故修完之后,这一层补了一道硬性约束——同一个 bug 簇在同一时刻只能有一份活跃修复尝试。要派第二份必须先等第一份转 RETURNED 或 COMPLETED。

这条约束放在数据库层强制(用 partial unique index——一种数据库层面的硬性约束,相当于在门口贴张"同一件事只允许一个人做"的告示),不是放在应用层判断。在数据库层强制业务规则这件事在普通业务里有争议(往往为了灵活性而避免),但在 AI 系统里几乎是刚需——AI 调用层面的约束不可信(agent 可能漏判、可能并发触发、可能在 retry 时绕过检查),必须靠数据层兜住。一行约束比一百行应用层 if-else 都好使。

v2 这一层还学了一件新东西:派发模块必须能识别 agent 的"liveness"(还活着没有)。v1 里只有"状态"概念没有"心跳"概念——issue 一旦派出去,没法区分"agent 在干活"和"agent 已经死了"。issue 反复被重派进死循环。后来加了 heartbeat、status_entered_at 两个字段,再加上 30 分钟冷却 + 5 次循环熔断的保护——一个不健康的 issue 不能再无限污染队列。

这是 AI 长任务系统区别于传统短任务系统的另一个特点——长任务必须显式建模"还活着"这件事。短任务可以靠"timeout 就重试"兜底,长任务不行,timeout 可能是 agent 正在做一次合理的长操作,瞎重试会越推越乱。

**【设计骨架 · 派发执行】**

**Push 路径:**

-   构造 HTTP 请求 → 按 callback_config 配 headers(auth_type ∈ {bearer, api_key, none})→ body 是归一化 schema
-   对方返回 dispatch_id → 写入 DispatchRecord → 状态机 CLASSIFIED → DISPATCHED
-   对外重试:超时 5s,指数退避 1s/2s/4s,最多 3 次,全失败转 RETURNED

**Pull 路径:**

-   暴露 `GET /api/v1/issues/pull?category=X&limit=N`
-   事务内 SELECT ... FOR UPDATE SKIP LOCKED LIMIT N → UPDATE status='DISPATCHED' → 返回
-   SKIP LOCKED 让两个拉取方不会互相阻塞

**关键约束:**

1.  同一 issue 同一时刻只能一份非终态 FixProgress——partial unique index 在数据库层强制
1.  所有对外动作幂等——idempotency key 由 (dispatch_id, issue_id, attempt_no) 拼成
1.  每个 dispatch 都带 heartbeat 字段,超过阈值(默认 30 分钟)触发 health check 或自动 RETURNED
1.  熔断:同一 issue 连续 RETURNED 5 次进入"需要 review"状态,不再自动重派

**扩展点:** 新接修复系统加一行注册表配置;Push/Pull 协议在配置里切换;callback_config 支持自定义 auth 插件。

派出去了。我们的视角暂时离开这条 bug——它现在在 agent 那一侧跑。但我们还需要跟踪它的状态。下一站是这套系统的脊柱。

7. 第五站:跟踪它走到哪一步

场景。这条 bug 派出去了,按理说我们这边的事就完了。但现实是——它在外面可能跑几十分钟到几小时,期间会发生很多事情:agent 正在分析、agent 写了一版被自己测试否了、agent 卡在某个环节、agent 想问我们一个澄清问题、agent 修完提了 MR 在等 CI、agent 自己放弃了要退回、甚至同一条 bug 三天后复发了又被报上来。

这些事情我们都得跟踪。 "它现在到底走到哪一步"是这套系统每个其它模块都要回答的问题——看板要显示进度、超时巡检要判断要不要 nudge、慢回路要分析哪些环节卡得最多、人工接管要知道当前能往哪个方向接。

这件事在传统业务里几个状态枚举就够(PENDING / PROCESSING / DONE / FAILED 之类)。在 AI 系统里完全不够,原因是 AI 任务有三个特殊性质:

  1. 生命周期长——单条任务分钟到小时级
  2. 可能多次重试——一条 bug 可能经历好几次修复尝试,每次都是独立的子生命周期
  3. 每次结果可能不同——重试不是简单"再跑一遍",是一个全新的尝试

v1 我们试过把状态揉进一个枚举里——PENDING / DISPATCHED / FIXING / MERGED / REOPENED / CLOSED 这样七八个值。第二周就崩了。

一条被 reopen 的 issue 要清空一堆字段(分配时间、修复开始时间、合并时间)、重置进度指标、还要保留历史诊断给下次参考。这些事在一个枚举上根本表达不清。每次 reopen 都要改 6-7 处字段,状态机文件膨胀到 400 行,新人一周都读不进去。更糟糕的是,监控 / 巡检 / 自动回复三个独立组件各自有自己的状态判断,跨组件状态不一致时会出现"已 RESOLVED 的 issue 又被错误地标回 pending"、"自动回复给死掉的 ticket 发消息"这种事——回滚那次清掉了 947 条脏 fix_progress 行和 6 条卡死的 pending review。

v2 拆成了两个并行的状态机,分而治之。用超市订单状态做个比方就好懂——"订单"本身有个状态(待支付、已支付、配货中、已发货、已签收),"这单的某次配送尝试"也有个状态(揽件、分拣、运输、派送、签收)。订单可能经历多次配送尝试(比如第一次没人收要重派),但订单本身的生命周期跟单次配送尝试的生命周期是不一样的。

外层状态机(Issue) ——跟踪粗粒度业务事实,这条 bug 现在归我们还是归第三方、是新进来的还是被复发重开的。

状态:PENDING → CLASSIFIED → DISPATCHED → PROCESSING → RESOLVED → CLOSED,外加 REOPENED 这个回环分支(复发了回去重派),再加两个去重终态 DUPLICATE / ARCHIVED

内层状态机(FixProgress) ——跟踪每次具体修复尝试的细粒度进展。每次 reopen 都新开一份 FixProgress,老的留存为历史。

状态:ASSIGNED → ANALYZING → FIXING → TESTING → MERGED → COMPLETED,加 BLOCKED(可中断挂起)和 RETURNED(修复方放弃)两个分支态。

两个状态机之间是 1:N 关系——同一个 Issue 可能产生多次 FixProgress。

拆开之后立刻好读了。外层只关心"在我们家还是在外面",内层只关心"这次修得怎么样"。两套生命周期解耦,每一套迁移规则可以独立演化。状态机文件从 400 行压回 200 行以内。

这是状态机设计里一个值得记住的通用经验——当你发现一个状态机的迁移规则越加越乱时,往往说明它在承担多个不同的生命周期,应该拆

架构上更关键的是统一写入入口——所有写状态字段的代码路径,必须走 state_machine 这一个文件,任何路由不允许绕过。这条约束让状态机文件本身就成了整套系统行为的"规约"。

新人上手这套系统,第一件事就是读状态机文件。AI 这一侧每次输出可能不一样,但状态机的迁移规则永远不变——读完这一个文件,整个系统的行为模型就掌握大半。AI 系统里"可推理性"是稀缺资源,状态机是少数能把它拿到手的地方。哪怕底层 AI 模型整体替换、prompt 全部重写,只要状态机这一层不动,整套系统的行为契约就稳得住。

**【设计骨架 · 双轨状态机】**

**外层 Issue 状态:**

-   活跃态:PENDING, CLASSIFIED, DISPATCHED, PROCESSING, REOPENED
-   终态:RESOLVED, CLOSED, DUPLICATE, ARCHIVED
-   合法迁移:PENDING→CLASSIFIED→DISPATCHED→PROCESSING→{RESOLVED, REOPENED},RESOLVED→{CLOSED, REOPENED},REOPENED→DISPATCHED

**内层 FixProgress 状态:**

-   活跃态:ASSIGNED, ANALYZING, FIXING, TESTING, MERGED, BLOCKED
-   终态:COMPLETED, RETURNED
-   合法迁移见 FP_VALID_TRANSITIONS 表,单文件枚举

**不变量:**

1.  所有写状态字段的代码路径必须走 state_machine 这一个模块,任何路由不允许直接 UPDATE
1.  每次写之前 SELECT FOR UPDATE 锁住对应 issue 行
1.  状态迁移和对应的事件写入必须在同一事务里——绕不开
1.  同一 issue 同一时刻只能一份非终态 FixProgress——partial unique index 强制
1.  终态是只读的,任何写入直接拒绝

**扩展点:** 加一个状态 = 在状态机文件加一行 transition rule + enum value + migration,流程是显式的、可 review 的。

状态机搭好了,我们继续看这条 bug。它现在在 agent 那一侧跑,状态从 DISPATCHED 转到了 PROCESSING,FixProgress 从 ASSIGNED 转到 ANALYZING。但 25 分钟过去了,没有新消息。它卡住了。

8. 第六站:它好像卡住了

场景。10:14:35 派出去,10:40 还没动静。FixProgress 停在 ANALYZING 已经 25 分钟,超过我们设的"P2 bug 最长分析时间 20 分钟"阈值。这是怎么回事?

三种可能:

  1. agent 正在做一次合理的长操作(比如一次复杂的 reasoning、一次很长的工具调用),还没说完一句完整的话
  2. agent 进了无效循环,反复想同一件事出不来
  3. agent 在等一个永远不会到的回复(比如等用户确认、等下游服务恢复)

这三种情况看上去一样,但最优处理动作完全不同——第一种最好别打扰它、第二种应该 nudge 它换个方向、第三种应该转 BLOCKED 等人介入。

这是 AI 长任务系统跟传统系统的一个根本差异。传统系统的 KISS 原则推崇"统一处理故障"——所有失败走一条退路,简单稳健(要么重试要么失败)。AI 系统反过来——故障语义越细分越好,因为每一种故障背后的最优动作不一样。区分故障比统一处理重要得多。

这一层我们 v1 完全没做——agent 跑就跑了,跑到超时被强杀就强杀,重派下一份。第一个月统计下来发现一件让人沮丧的事:近 40% 的任务死在了"卡住超时"上,而不是"修不了"上。也就是说有四成任务 agent 其实正在跑、有想法、有诊断,但因为某一步在等一个永远不会到的回复,整个任务卡死直到超时被杀。一次修复尝试白做、几十分钟时间白用、还要等被杀重派一份,端到端 latency 翻倍。

v2 拆出两个层级来解。

  1. Chat 层。agent 和系统之间的 WebSocket 双向通道,所有对话走这里,每条消息落库。这一层本身不做决策,只做消息搬运——像车间的对讲机。
  2. Conversation Driver。一个定时巡检(每分钟一次),扫描所有"超过 N 分钟没有新消息"的活跃 FixProgress,按 SLA 等级和当前状态机的态决定动作:给 agent 发提醒("还在做吗?建议换个方向查 X 模块")、或转 BLOCKED 等人介入、或 RETURNED 重新派单。策略表是数据驱动的,运营可以调"N 分钟"和"对应动作"两个参数。

就像工厂的巡检员——每隔一段时间在车间走一圈,看哪个工位卡住了,去问一声"你在做什么?要不要换个工具?"。

这一层后来还演化出了 Supervisor Agent——一个专门负责"判断哪些卡住的任务该往哪推"的 agent 角色。它本身也是个 agent,但只观察和推动其它 agent,自己不写代码。这是这套系统里第一次出现"AI 治理 AI"的回路。Supervisor 上线时是带灰度的——前两周只在 10% 的卡住任务上跑,效果好于硬规则之后才扩到全量。这个流程被沉淀成内部模板:任何新的"AI 治理 AI"能力都先灰度、再扩量、最后才替换原来的硬规则

前端的 Chat 页面也是这一层的一部分。人可以直接插话、改 prompt、强制接管,看到 agent 当前所有上下文。这是人机交互层在 AI 系统里不是辅助、是底座的具体体现——没有这个面板"自动跑"就不敢真自动;有了之后任何卡住的任务都能在 30 秒内找到人介入入口。

**【设计骨架 · 会话与推动】**

**Chat 层:** WebSocket 双向通道。chat_messages 表(conversation_id, role, timestamp, payload JSONB)。所有对话落库,前端实时订阅。

**Conversation Driver 策略表:** (severity, current_state, idle_minutes) → action ∈ {nudge, escalate_to_human, return_to_dispatch}。每分钟扫一次,按表执行。

**Supervisor Agent:** 读取卡住 FixProgress 的全部上下文(事件流、对话历史、当前态),输出建议动作。先灰度 10% 再全量。

**不变量:**

1.  所有 nudge / escalate / return 动作落事件表,可审计
1.  人工接管随时可发起,优先级最高,覆盖一切自动决策
1.  策略表改动走慢回路评测,不能直接生效

Conversation Driver 给这条 bug 的 agent 发了一条 nudge:"建议检查上游服务的响应",agent 收到,5 分钟后给出诊断,开始写 patch。10:55 完成、提了 MR、CI 跑过、合进 master。FixProgress 从 ANALYZING → FIXING → TESTING → MERGED → COMPLETED,Issue 从 PROCESSING → RESOLVED。

到这里这条 bug 的旅程就完了。但我们还差一站——确保下次出问题能快速回看。

9. 第七站:所有动作都登记下来

场景。回到开头那个事故。一个多小时定位时间里,绝大部分不是花在改,是花在「拼当时发生了什么」——查 Sentry、查工单、查派单日志、查 agent log、对时间戳。每个系统的时间戳精度还不一样,对到秒就得猜,跨系统的因果关系靠脑补。

这一小时让我们想清楚一件事——AI 系统出事时,唯一能依赖的是把当时所有相关上下文按时间序还原回来。这件事必须从 day 1 就建出来,事后补不上。

这是 AI 系统跟传统业务系统在审计这件事上的根本差异。传统系统出问题,往往可以靠"再跑一次"复现——同样输入大概率得到同样输出。AI 系统不行:同一条 bug 派给同一个 agent 跑两次可能得到两个不同的 patch,事故现场只存在一次。审计不能是事后才上的辅助功能,必须是和业务动作原子写在一起的一等设施。

这件事在架构上叫"事件总线"。但物理上它就是 PostgreSQL 里一张表——叫 issue_events。append-only(只追加),永远不修改也不删除。

每条事件的字段签名是固定的五个:

  1. issue_id — 关联到哪条 issue
  2. type — 事件类型枚举(CLASSIFIED / DISPATCHED / FIX_STARTED / LLM_CALLED / TIMEOUT / HUMAN_OVERRIDE / DEDUPED 等)
  3. payload — JSONB,事件类型特定的结构化数据(AI 调用就存 prompt + response + token,状态迁移就存 from + to + reason)
  4. timestamp — 微秒精度
  5. actor — 谁触发的(system / agent_id / user_id)

所有跨模块的状态变化、AI 调用入参出参、外部动作、人工介入,统一写这一张表。至今表里有几百万行。前端时间线读它、事故复盘读它、慢回路演化也读它。

这张表就像工厂的广播登记簿——任何动作发生都有个工作人员负责广播一声(系统自动登记),所有广播都被一字不差记下来。事故复盘的时候不需要再去问当事工人"你当时怎么想的",翻一下登记簿就知道每个时刻每个工位发生了什么。

最关键的工程约束是:事件写入和它对应的业务动作必须在同一事务里完成。状态变了但事件没记下来,事后等于没变;事件记下来了但状态没变,事后审计会得到错误的因果链。这条约束被强制写进状态机模块——任何状态迁移函数都要求传入对应的事件入参,函数体里 UPDATE 状态和 INSERT 事件在同一个事务里,要么全提交要么全回滚。

这条单事务约束在传统业务里也是好实践,但在 AI 系统里是必需品。AI 调用一旦发生就不能撤销——prompt 已经发了、response 已经生成了、token 已经从余额扣了,这些副作用没法事务化。但你可以保证"这次调用的元数据被记录"和"这次调用的业务后果"在数据层是原子的。后者做不到,整个系统的审计能力就是漏的。

开头那个事故如果发生在现在,定位不会超过 10 分钟——一个 SQL 就拉出整条时间线,所有跨模块的因果关系一目了然。

这张表是这套系统的"地心引力"——所有模块都被它拽着走。事件不全意味着无法回放,无法回放意味着出问题没法定位,没法定位意味着系统不敢上线。这一层的工程纪律性必须超过其它所有模块——它是 AI 系统的可解释性底座,少这一块系统就不敢上线。

**【设计骨架 · 事件流】**

**表结构:** (issue_id BIGINT FK, type ENUM, payload JSONB, timestamp TIMESTAMPTZ, actor VARCHAR),append-only,按 issue_id + timestamp 复合索引。

**写入约束:** 事件 INSERT 和对应的业务 UPDATE 必须在同一数据库事务里——靠状态机模块的强制注入实现。任何模块要写 state,必须同时传入对应 event。

**不变量:**

1.  append-only:永远 INSERT,永远不 UPDATEDELETE。已写入的事件是不可变历史。
1.  事件和业务动作原子——状态变了但事件没记 / 事件记了但状态没变,两种情况都不允许出现
1.  type 字段是 enum,新类型必须经过 schema migration——避免随手 INSERT typo 把后续 query 打乱
1.  payload 是 JSONB(不是 JSON)——JSONB 有 GIN 索引,可以按字段查询
1.  actor 是显式列(不是埋在 payload 里)——审计场景下"谁做的"是高频维度,独立成列才能走索引

**扩展点:** 新增事件类型只改 enum 和 payload schema,不动其它任何模块;新增消费者只是新增读取者,不影响生产者。

这条 bug 的旅程到这里就走完了——从进系统到关闭,整套系统在事件表里留下大约 30-40 条事件记录,整条因果链可以一个 SQL 拉出来。

但还有一件事——我们要确保下次类似的 bug 进来,系统的处理能比这次更好。这是最后一站。

10. 第八站:让系统越跑越准

场景。系统跑一段时间一定会暴露问题——某类 bug 总是被分类错、某个 agent 在某种 category 下总是退回、某条去重规则在特定 title 模板上失效。这些不是 bug,是系统对真实世界的拟合不足

靠人工修配置当然可以,但十个人的团队搞不动几百条规则的持续运维,整套系统会一直停在"半自动"。

但如果让 agent 自己在运行时改自己的 prompt 或规则呢?这又会立刻打破前面所有可审计的承诺——一个能自己改自己的运行时,相当于一个无法 freeze 的系统,事故现场永远抓不住。这一刻你看到的行为下一刻可能不存在了,事后审计完全做不了。

这中间需要第三条路径。架构上的破局点是——把"演化"和"运行"在时间维度上拆开,让两条回路按不同节奏跑,互不污染。

用月度复盘会做个比方就好懂。工厂每个月开一次复盘会——汇总这个月所有事故、所有效率指标、所有工人反馈,讨论哪些工艺规则要调整。讨论出来的方案不会立刻上车间,而是先做小范围试点、对比效果、确认没问题再全厂推广。运行时的工艺规则和复盘会讨论的"下一版工艺规则"是两份独立的文档,前者只能改正在生效的版本,后者要走完整流程才能成为下一个生效版本。

具体怎么落地:

  1. 快回路(运行时) 。只读不写。读已发布的配置(去重规则、分类规则、路由表、SLA 策略)跑当前任务,把每次跑的结果信号沉淀下来——哪一层命中、是否被人工修正过、有没有触发 RETURNED、超时发生在哪一步。这些信号写进事件总线,是它的另一种消费场景。

  2. 慢回路(离线) 。每周跑一次定时任务,汇总上一周的信号反推下一版配置:哪几个 category 的人工修正率超过阈值、哪些 title 模板被多次错判去重、哪些路由权重组合在过去 N 周表现稳定低于平均。

    1.   任何改动不是落库即生效,是生成一个 proposal 记录——附带变更前后的 diff、生成它的信号依据、proposer 标识、创建时间。proposal 是离线产物,跟运行时配置完全隔离。
    2.   proposal 必须经过评测门——拿改动前后两版配置各跑 200 条历史 bug 做 A/B 回归。关键指标(去重召回率、分类准确率、自动闭合率、平均 latency)任意一个显著回退,proposal 自动 reject 并写明原因。
    3.   评测通过的 proposal 进入发布通道——人工审核 + 灰度上线(先 10% 流量、观察 24 小时、再扩量)。发布之后的新配置写入"已发布配置表",运行时读这张表跑任务。每次发布都保留前 N 个版本,新版本上线后表现异常可以一键 rollback。
  3. 真实案例。上个月慢回路汇总信号时发现:所有 title 里带「[Hotfix]」「[Urgent]」「[紧急]」这类前缀的 bug,pg_trgm 相似度计算被前缀干扰,本该合并的两条被判成新 issue 的比例明显偏高。生成的 proposal 是「在去重归一化 step 里识别并剥除这类方括号前缀」。评测引擎跑回归——改动前 200 条样本里 7 条误判,改动后 1 条,召回率提升 3 个百分点,其它指标无回退。proposal 通过,灰度 10% 三天无异常,扩到全量。

这种"全局视角才看得到的模式",运行时单条 bug 永远发现不了,但慢回路里两周时间能稳定走完一轮。

架构上这条快慢分离不是我们发明的。推荐系统的"在线 ranking + 离线 retrain"、风控的"在线规则 + 离线策略迭代",本质都是同一个模式,差异在于命名和领域,骨架完全同构。任何一个声称"自我演化"的 AI 系统,如果没拆出这两条回路,要么是噱头要么是事故待发。

跑到现在,离线回路每周稳定产出 3-5 条 proposal,发布通过率大概 60%。量不算大但持续在累积——半年下来差不多 50 条配置改进,每一条都有评测背书、有 diff 历史、有 rollback 入口。

「Self-Evolution」(自演化)这个词在我们这里,边界划得比命名严——只演化配置和规则,不演化运行时代码,更不演化运行时 prompt。

**【设计骨架 · 慢回路演化】**

**三张关键表:**

1.  `config_versions`:已发布配置版本表,运行时只读这一张
1.  `proposals`:proposal 记录(diff、依据信号、状态、评测结果)
1.  `eval_runs`:评测运行记录(哪个 proposal、哪些样本、各项指标对比)

**流程:**

1.  快回路:运行时读 config_versions 最新 active 版本,每次任务把信号写进事件流
1.  慢回路 cron(每周):扫上周事件流,聚合分析,生成 proposals 记录
1.  评测门:对每个 proposal 跑 A/B 回归(用 200 条历史样本),结果写 eval_runs
1.  人工审核 + 灰度发布:通过的 proposal 经过 review 后写入 config_versions,灰度 10% → 全量

**不变量:**

1.  运行时永远只读 config_versions 的 active 版本,永远不读 proposals 或写任何配置
1.  任何改动必须经过评测门——关键指标不回退才允许发布
1.  每次发布都有 rollback 入口,前 N 版可回滚
1.  运行时和慢回路用代码层 + IAM 权限两层隔离,杜绝意外耦合

这条 bug 的旅程到这里完整结束了——从进系统到关闭、从单次处理到沉淀进慢回路演化、整套系统在事件表里留下完整审计、下次类似的 bug 进来系统会比这次更准。

下面讲两个跟具体 bug 旅程没关系但很重要的元层面话题。

11. 为什么我们最后没拆成微服务

8 个模块讲完,回到一个被反复问的问题——这些模块为什么不拆成 8 个微服务。

"标准答案"是每个模块一个服务,加上 Kafka 做消息总线、Redis 做共享状态、ES 做相似度索引。我们 v1 就是这么画的,画到第三版自己劝退了——这套架子搭起来跑通要两个月,跑通之后维护成本至少是现在的三倍。十个人的团队养不起。

最后落下来的版本反着来——8 个模块全压在一个 FastAPI 进程里,一份 PostgreSQL 做唯一数据源,前端一个 React SPA,再加 nginx 反向代理,整个系统跑起来就这四个进程。

这件事能成立的根本原因是 AI 工作负载本身的吞吐量天然就低——单条任务时延分钟级,同一时刻活跃任务最多几百个。在这个量级下,单 PostgreSQL 实例完全是吃不饱的状态,消息队列是为吞吐而生的组件在这里解决的是不存在的问题。上了反而带来一致性问题——"派单成功但状态没更新"这种脏态在跨组件设计里是必然的,在单进程单库的设计里被一行事务写干净。

PostgreSQL 在这里不只是数据库,它承担了消息队列、分布式锁、搜索引擎、状态机存储、事件日志、配置中心六个角色。这六合一在百万 QPS 的传统业务里不成立,在分钟级吞吐的 AI 系统里恰恰成立——因为 AI 负载的吞吐天花板远低于 PostgreSQL 单实例的能力上限。

这套权衡总结起来就一句:当 AI 是工作负载里最贵的那一项时,所有不直接服务 AI 的工程开销都应该被压到最小。注意力应该花在"AI 这一侧不可预测"的部分,不应该花在"我们这一侧本可以简单"的部分。

架构师在这里要做的判断是——抵抗"看起来更专业"的诱惑。一个组件不带来对应价值就不要引入。所谓"为未来扩容预留",大多数时候等不到未来就先被复杂度拖垮。架构债是利息很高的债

12. 三件刻意没做的事

讲完做了什么,讲讲刻意没做什么。这部分往往比做了什么更能说明设计意图。

  1. 没做运行时 prompt 改写

这件事看着诱人——既然 agent 可以自己写代码,为什么不能让它自己改自己的 prompt?答案是「AI 行为必须可审计可回放」这条底线。一个能自己改 prompt 的运行时等同于一个无法 freeze 的系统——这一刻你看到的行为下一刻可能不存在了,事后审计完全做不了。Prompt 演化走慢回路,运行时永远只读。这条约束我们立得很硬。

  1. 没做跨仓库的根因分析

跨仓库 bug 直接转人工。原因是跨仓库根因依赖跨仓库语义理解,单 agent 上下文不足以承载这件事,prompt 调多少都没用。我们试过,失败率比单仓库高几倍,还经常给出看起来对、跑下来错的 patch。强行放进自动链路只会拉低整体可信度,干脆不做。

  1. 没做对外契约相关的自动合并

涉及付款、计费、对外 API 契约的 patch,agent 可以写、可以提 MR、可以跑测试,但合并这一步永远要人工 approve。这条约束写在派发层策略里,agent 看到这类 category 就走"待人工 approve"的旁路。副作用越贵的事,自动化的边际收益越低——这条原则在我们这里被显式写进了代码。

这三条加起来覆盖大约 15% 的真实 bug 量。剩下 85% 跑自动链路,端到端自动闭合率稳定在 60% 上下。

这个数字不算高,但意义在另一处——这 85% 在自动链路出现之前要 oncall 全程跟,现在 oncall 只看 Console 上飘红的那几条。注意力从"跟所有 bug"收敛到"跟需要判断的 bug"。这才是这套系统真正释放出来的价值,不在闭合率本身。

13. 回过头看

这套系统从立项到现在大半年。回过头看,写得对的不是哪个聪明算法,是几条贯穿始终的架构判断。挑三条最狠的留在这里。

  1. 架构形状要跟着工作负载走,不要跟着行业模板走

反面是 v1——七组件五层的"标准 AI 系统",跑两周自己投票推倒。代价:两周白干。

正面是 v2——四个进程一份数据真相,跑下来半年没出过架构层面的事故。结论:在 AI 系统里,"看起来规范"和"实际有用"经常不是一回事。判断标准只有一个——这套架构匹不匹配我们的工作负载形状。

  1. 越是 AI 不确定的地方,外围工程结构就越要确定

反面:让 agent 在运行时改自己的 prompt——听起来很 AGI,实际是把系统拖进无法 freeze 的状态,事故现场永远抓不住。

正面:分类与路由这一层是整个引擎 AI 含量最高的,但它在结构上反而被要求做得最像纯函数。围绕 AI 调用的代码必须比 AI 调用本身简单一个数量级。这条原则在系统每个角落都能反复看到。

  1. 把"演化"和"运行"在时间维度上拆开

反面:让运行时自己改自己——快是快,但不可控。

正面:运行时只读、离线写、评测门兜底。这是 AI 系统能持续演进而不失控的唯一可行路径,推荐系统几十年的经验早就验证过。

下一版最想动的一处是会话推动那一层。现在的"卡住就 nudge / 转 BLOCKED / 退回"策略还是数据驱动但人写的规则,最近在想把整套策略本身变成一个 supervisor agent——让一个专门的 agent 去判断那些卡住的任务该往哪推。如果这件事走通,意味着这套系统里第一次有了完整的"AI 治理 AI"回路。当然,这条回路同样要走慢回路评测兜底,运行时永远只跑已验证的策略。

第三篇会讲那 9 个仓库里另一个——Agent Runtime。这一篇讲的引擎管的是多个 agent 之间的协作,Runtime 管的是单个 agent 内部的执行——工具调用、上下文管理、流式输出、断点恢复。两边的架构取舍极不相同。工厂类比里那个"单个工人"内部长什么样,下一篇细讲。