接着第一篇往下讲。这次单独拿出那条「bug 进 MR 出」的自动化链路。我们用一条具体的线上 bug 串起整篇——它怎么进系统、怎么被识别、怎么被派出去、怎么被修好、怎么知道下次不再犯。每经过一个环节,讲清楚那一层为什么这么设计。仅供大家参考。
1. 先讲一次差点把系统下线的事故
系统上线大约一个月时,出过一次凌晨出现 bug 的事。
事情本身不复杂。同一条线上 bug 被两个上游系统同时报上来——一份从用户工单进来、一份从前端的错误监控进来。这两份报告长得不太一样:工单的描述是用户写的中文,错误监控的描述是带 trace 的英文。但它们讲的是同一件事,根因是一行代码改错了类型。
系统当时的去重逻辑没识别出这是同一条 bug,把它当成两条独立任务派了出去。两个 agent 同时接,各自分析、各自写补丁、各自提了 MR。其中一个先合进 master,另一个的 CI 跑过去发现冲突,agent 自动 rebase 想解决,把前一个的修复给覆盖回去了一半。十分钟后线上重新开始报错。
从告警到回滚一个多小时。真正动手改的时间不到 10 分钟,剩下 50 分钟全花在「拼当时发生了什么」——查工单系统、查错误监控、查我们自己的派单日志、查两个 agent 的对话记录、对时间戳。每个系统的时间戳精度还不一样,对到秒就得猜。
第二天复盘,讨论到最后,发现其实核心的就是三件事。
- 这次出错的根因不在任何一个模块的代码里,在三个模块的边界假设互相不一致——去重那边以为派单会兜底并发,派单那边以为去重已经判过了,合 MR 那侧假设上游不会派重复任务。三方各自都没错,叠起来就漏了。
- 定位 50 分钟、修复 8 分钟,这个比例如果不调过来,再来几次半夜睡眠质量就保不了了。
- 之前所有讨论都在 agent 怎么修代码,没人讨论过 agent 外面那一层应该长什么样。
那天之后,我的注意力挪到了一件事上:从「让 agent 跑起来」转到「设计 agent 外面那一层」。这一层后来叫问题自修复引擎。它自己不写代码、不诊断 bug、不调用 AI——它只负责一件事:让里面那群 AI agent 在生产环境里以可控、可审计、可恢复的方式运转。
这一篇要讲的就是这一层。我们用一条具体的线上 bug 把它的 8 个模块串起来——你跟着这条 bug 走完一遍,自然就能看懂整套系统在做什么、为什么这么做、哪些地方是被生产环境逼着改过的。
2. 建立心智模型
开始拆模块之前,先讲一个我们自己用的心智模型(这个比喻在后面每一节都会回来)。
你可以把这套系统想象成一座小工厂。AI agent 是工人,bug 是进厂的原料,修复完合并的 MR 是出厂的成品。中间需要的东西就一目了然:
- 一条传送带把原料运进来(这是采集层)
- 一道分拣工位决定这块料归哪个工人处理(这是分类与派单)
- 每个工位边上贴一张工艺路线卡记录这块料走到哪一步(这是状态机)
- 所有动作进车间广播登记在册(这是事件流)
- 车间主管手边永远有一个红色按钮可以叫停某条产线(这是人机接管)
- 每个月开一次复盘会,根据这个月数据调整工艺(这是慢回路演化)
这个类比可能不太直观,但在后面几个具体决策上都有真实映射体现。
比如「为什么状态信息要落数据库,不放 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(瀑布式兜底,热路径走快的、冷路径走慢的)。
用邮局分拣员做个比方比较好懂:
- 第一层(指纹精确) :先按邮编投递——同一上游同一时间窗多次推送,三段拼接的指纹完全一样,直接合并。
- 第二层(来源 ID 精确) :邮编不对再看门牌号——同一工单被改了 title 但 ID 没改,按 (上游系统, 上游 ID) 联合匹配能识别。
- 第三层(字符相似度模糊) :门牌号都不对,就看地址相似度——基于 PostgreSQL 的 trigram 索引算字符相似度,阈值 0.8,时间窗只看最近 180 天的活跃 bug。
三层串行,前一层命中后一层不跑。最后命中是哪一层会被记到事件表里——事后出问题想反查"这两条为啥被合并",能直接看到证据:是指纹一致、还是来源 ID 一致、还是字符相似度 0.85。
这一层故意没上 embedding。讨论过两次每次都被同一个理由劝退——AI 系统出事故最难受的不是错,是不知道为什么错。字符相似度的命中证据是字面的,事后追问"这两条为啥被合并"拿得出来;向量距离则解释不清,"距离 0.83 所以判同源"这种话讲不通也没法 debug。等哪天 pg_trgm 真的撞墙,再上 embedding 也会作为第四层在更后面——可解释的兜底永远在更靠前的位置。
开头那次事故就是 v1 漏判触发的。事故后 v2 做了两件事补救:
- 扩大了第二层匹配的字段集合——除了原来的 (上游系统, 上游 ID),又给每个上游注册了它特有的身份字段(错误监控的 culprit hash、APM 的 trace 指纹、内部 alerting 的 incident id)。这次扩面没改 dedup 模块代码,靠的是配置表里加几行——又一次把"代码硬编码"变成"配置驱动"。
- 在派单层加了硬性并发互斥——同一个 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 分离(决策依据和决策动作拆开——依据靠数据驱动可以演化,动作只负责按依据执行)。用医院的比方:分诊台护士先判断病人去哪个科(分类),挂号系统再按科室找一个有空的医生(路由)。两件事是分开的人和分开的流程。
我们具体落成两个组件。
- Classifier(分类器): 一次轻量 AI 调用,把 bug 候选按预定义的 category 树打标,每条标 1-3 个 category 和置信度("这是前端 bug,置信度 0.92")。category 树本身在数据库里,运营可以改。
- 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 已经派给外部了,对方开始干活了,这个"派出去"的事实没法撤回。
所以派发层在架构上的定位很特殊——它是整个系统第一个产生"对外副作用"的地方,是对外世界的统一边界。它做三件事,每件都是为了让边界外的不可控不要污染边界内的清晰。
- 第一件:协议适配。我们接的修复系统不全一样。有的是常驻在线的 agent 平台(可以接收回调),有的是间歇上线的——比如夜间停机的内部工具、容量受限的服务,只能它有空了来拉。如果只支持一种,能接的系统就少一半。
所以双协议都做。推(Push) 模式下,派发模块主动构造 HTTP 请求把 bug 推过去,对方返回一个 dispatch_id 写进我们的数据库。拉(Pull) 模式下,第三方主动调我们的一个统一接口,按它能处理的 category 拉一批走。每个修复系统注册时选自己的模式。
关键是不让协议差异渗透到内部状态机——无论是推还是拉,对状态机这一侧看到的都是同一种状态转移。v1 里我们一度让两种协议各自走不同的状态枚举,状态机文件膨胀到几百行没人读得懂;v2 收敛之后内部状态机只剩一套,协议差异完全压在派发层自己内部。
- 第二件:幂等。每个对外动作都要写得像在跟一个不可靠的远程服务交互——超时(5 秒)、重试(指数退避最多 3 次)、降级(推不通转 RETURNED 重新派)、幂等键(dispatch_id + idempotency_key 拼一起,保证重放不会真重派)。
幂等这件事在 AI 系统里特别重要。重复派一次 bug 可能导致两个 agent 撞车、可能让对方计费两次、可能触发对方限流——副作用是真金白银。
- 第三件:并发互斥。开头那次事故修完之后,这一层补了一道硬性约束——同一个 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 任务有三个特殊性质:
- 生命周期长——单条任务分钟到小时级
- 可能多次重试——一条 bug 可能经历好几次修复尝试,每次都是独立的子生命周期
- 每次结果可能不同——重试不是简单"再跑一遍",是一个全新的尝试
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 分钟"阈值。这是怎么回事?
三种可能:
- agent 正在做一次合理的长操作(比如一次复杂的 reasoning、一次很长的工具调用),还没说完一句完整的话
- agent 进了无效循环,反复想同一件事出不来
- agent 在等一个永远不会到的回复(比如等用户确认、等下游服务恢复)
这三种情况看上去一样,但最优处理动作完全不同——第一种最好别打扰它、第二种应该 nudge 它换个方向、第三种应该转 BLOCKED 等人介入。
这是 AI 长任务系统跟传统系统的一个根本差异。传统系统的 KISS 原则推崇"统一处理故障"——所有失败走一条退路,简单稳健(要么重试要么失败)。AI 系统反过来——故障语义越细分越好,因为每一种故障背后的最优动作不一样。区分故障比统一处理重要得多。
这一层我们 v1 完全没做——agent 跑就跑了,跑到超时被强杀就强杀,重派下一份。第一个月统计下来发现一件让人沮丧的事:近 40% 的任务死在了"卡住超时"上,而不是"修不了"上。也就是说有四成任务 agent 其实正在跑、有想法、有诊断,但因为某一步在等一个永远不会到的回复,整个任务卡死直到超时被杀。一次修复尝试白做、几十分钟时间白用、还要等被杀重派一份,端到端 latency 翻倍。
v2 拆出两个层级来解。
- Chat 层。agent 和系统之间的 WebSocket 双向通道,所有对话走这里,每条消息落库。这一层本身不做决策,只做消息搬运——像车间的对讲机。
- 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(只追加),永远不修改也不删除。
每条事件的字段签名是固定的五个:
issue_id— 关联到哪条 issuetype— 事件类型枚举(CLASSIFIED / DISPATCHED / FIX_STARTED / LLM_CALLED / TIMEOUT / HUMAN_OVERRIDE / DEDUPED 等)payload— JSONB,事件类型特定的结构化数据(AI 调用就存 prompt + response + token,状态迁移就存 from + to + reason)timestamp— 微秒精度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,永远不 UPDATE 或 DELETE。已写入的事件是不可变历史。
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 的系统,事故现场永远抓不住。这一刻你看到的行为下一刻可能不存在了,事后审计完全做不了。
这中间需要第三条路径。架构上的破局点是——把"演化"和"运行"在时间维度上拆开,让两条回路按不同节奏跑,互不污染。
用月度复盘会做个比方就好懂。工厂每个月开一次复盘会——汇总这个月所有事故、所有效率指标、所有工人反馈,讨论哪些工艺规则要调整。讨论出来的方案不会立刻上车间,而是先做小范围试点、对比效果、确认没问题再全厂推广。运行时的工艺规则和复盘会讨论的"下一版工艺规则"是两份独立的文档,前者只能改正在生效的版本,后者要走完整流程才能成为下一个生效版本。
具体怎么落地:
-
快回路(运行时) 。只读不写。读已发布的配置(去重规则、分类规则、路由表、SLA 策略)跑当前任务,把每次跑的结果信号沉淀下来——哪一层命中、是否被人工修正过、有没有触发 RETURNED、超时发生在哪一步。这些信号写进事件总线,是它的另一种消费场景。
-
慢回路(离线) 。每周跑一次定时任务,汇总上一周的信号反推下一版配置:哪几个 category 的人工修正率超过阈值、哪些 title 模板被多次错判去重、哪些路由权重组合在过去 N 周表现稳定低于平均。
- 任何改动不是落库即生效,是生成一个 proposal 记录——附带变更前后的 diff、生成它的信号依据、proposer 标识、创建时间。proposal 是离线产物,跟运行时配置完全隔离。
- proposal 必须经过评测门——拿改动前后两版配置各跑 200 条历史 bug 做 A/B 回归。关键指标(去重召回率、分类准确率、自动闭合率、平均 latency)任意一个显著回退,proposal 自动 reject 并写明原因。
- 评测通过的 proposal 进入发布通道——人工审核 + 灰度上线(先 10% 流量、观察 24 小时、再扩量)。发布之后的新配置写入"已发布配置表",运行时读这张表跑任务。每次发布都保留前 N 个版本,新版本上线后表现异常可以一键 rollback。
-
真实案例。上个月慢回路汇总信号时发现:所有 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. 三件刻意没做的事
讲完做了什么,讲讲刻意没做什么。这部分往往比做了什么更能说明设计意图。
- 没做运行时 prompt 改写。
这件事看着诱人——既然 agent 可以自己写代码,为什么不能让它自己改自己的 prompt?答案是「AI 行为必须可审计可回放」这条底线。一个能自己改 prompt 的运行时等同于一个无法 freeze 的系统——这一刻你看到的行为下一刻可能不存在了,事后审计完全做不了。Prompt 演化走慢回路,运行时永远只读。这条约束我们立得很硬。
- 没做跨仓库的根因分析。
跨仓库 bug 直接转人工。原因是跨仓库根因依赖跨仓库语义理解,单 agent 上下文不足以承载这件事,prompt 调多少都没用。我们试过,失败率比单仓库高几倍,还经常给出看起来对、跑下来错的 patch。强行放进自动链路只会拉低整体可信度,干脆不做。
- 没做对外契约相关的自动合并。
涉及付款、计费、对外 API 契约的 patch,agent 可以写、可以提 MR、可以跑测试,但合并这一步永远要人工 approve。这条约束写在派发层策略里,agent 看到这类 category 就走"待人工 approve"的旁路。副作用越贵的事,自动化的边际收益越低——这条原则在我们这里被显式写进了代码。
这三条加起来覆盖大约 15% 的真实 bug 量。剩下 85% 跑自动链路,端到端自动闭合率稳定在 60% 上下。
这个数字不算高,但意义在另一处——这 85% 在自动链路出现之前要 oncall 全程跟,现在 oncall 只看 Console 上飘红的那几条。注意力从"跟所有 bug"收敛到"跟需要判断的 bug"。这才是这套系统真正释放出来的价值,不在闭合率本身。
13. 回过头看
这套系统从立项到现在大半年。回过头看,写得对的不是哪个聪明算法,是几条贯穿始终的架构判断。挑三条最狠的留在这里。
- 架构形状要跟着工作负载走,不要跟着行业模板走。
反面是 v1——七组件五层的"标准 AI 系统",跑两周自己投票推倒。代价:两周白干。
正面是 v2——四个进程一份数据真相,跑下来半年没出过架构层面的事故。结论:在 AI 系统里,"看起来规范"和"实际有用"经常不是一回事。判断标准只有一个——这套架构匹不匹配我们的工作负载形状。
- 越是 AI 不确定的地方,外围工程结构就越要确定。
反面:让 agent 在运行时改自己的 prompt——听起来很 AGI,实际是把系统拖进无法 freeze 的状态,事故现场永远抓不住。
正面:分类与路由这一层是整个引擎 AI 含量最高的,但它在结构上反而被要求做得最像纯函数。围绕 AI 调用的代码必须比 AI 调用本身简单一个数量级。这条原则在系统每个角落都能反复看到。
- 把"演化"和"运行"在时间维度上拆开。
反面:让运行时自己改自己——快是快,但不可控。
正面:运行时只读、离线写、评测门兜底。这是 AI 系统能持续演进而不失控的唯一可行路径,推荐系统几十年的经验早就验证过。
下一版最想动的一处是会话推动那一层。现在的"卡住就 nudge / 转 BLOCKED / 退回"策略还是数据驱动但人写的规则,最近在想把整套策略本身变成一个 supervisor agent——让一个专门的 agent 去判断那些卡住的任务该往哪推。如果这件事走通,意味着这套系统里第一次有了完整的"AI 治理 AI"回路。当然,这条回路同样要走慢回路评测兜底,运行时永远只跑已验证的策略。
第三篇会讲那 9 个仓库里另一个——Agent Runtime。这一篇讲的引擎管的是多个 agent 之间的协作,Runtime 管的是单个 agent 内部的执行——工具调用、上下文管理、流式输出、断点恢复。两边的架构取舍极不相同。工厂类比里那个"单个工人"内部长什么样,下一篇细讲。