ysoseri.us

实习日志 / 中文 / Agent 的控制与编排 / 肉选错了:十四轮补丁之后,从用户旅程重写 agent

肉选错了:十四轮补丁之后,从用户旅程重写 agent

中文 | English

做一道鱼香肉丝,我们先前一直在研究配料、配菜和火候,可这道菜的肉应该用过油的梅花肉,我们用的却是裹了淀粉的后腿肉。肉选错了,配料再讲究、火候再准也救不回来。这是 9 月中旬我对 Agent 说的一个比喻,用来形容我们的报案 agent:大家在提示词、规则和各种修补上花了很多功夫,可"客户经理此刻该做什么,由谁决定"这件根本的事,一直交给了不该做这个决定的东西。

这篇记的是 9 月 18 日到 23 日围绕这个比喻发生的事。起点是一个只有一句话的 issue,"agent orchestration";终点是一个改了一万四千多行的 PR,以及一份后来成为所有改动裁判标准的短文档。中间走了一条很长的弯路:先是十四轮补丁,每一轮都在研究配料和火候,直到我自己用浏览器走了一遍,才确认肉选错了。

起点:去掉一个下拉框

当时报案页的输入框旁边有一个理赔类型下拉框,选项是车险、房屋、财产,外加一个 Let Agent identify。我们决定把它整个拿掉:用户不应该为了开始讲事故,先去理解 Northwind 内部对险种的分类。自然语言识别险种的能力还留在运行时里,删掉的只是让用户自己选这件事。

拿掉下拉框,牵出了整个编排结构的问题。我先写了一份编排收敛方案,核心是几条边界:

  • 本轮险种只有一个权威结论。 新增一个无副作用的 TurnFamilyResolver,按"已持久化的险种 → 已确认的字段 → 本轮唯一明确的候选 → 受约束的分类结果 → 都不行就 unresolved"的顺序解析,输出险种、状态(权威、推断、模糊、冲突)、来源和是否需要确认。路由、上下文、字段合同和第三方服务都只消费这一个结果,不许各自再用正则或旧字段推一遍。
  • 核心回复和可选能力分开。 一个回合至少有两种结果:对话结果和若干可选能力的结果。核心回复无效,整轮失败、状态不变;核心回复有效、某个第三方候选不合格,聊天照常保存和返回,只是不生成那张卡片,内部记下拒绝原因。可选能力的失败不能包装成"模型服务没能完成请求"。
  • 文案和卡片要一起降级。 只删掉失败的卡片、却留着模型写的"我已经帮你安排了定损",是不行的。"已提交、已预约、已联系、已完成"这类话,必须和运行时真实的执行结果绑定。
  • 第三方调用的失败进它自己的生命周期。 适配器超时或抛异常,进入可重试、终止失败或结果未知的状态,不能逃逸成整个聊天接口的失败。要消除的是一种真假分裂:消息和回复已经落库,后面的适配器抛了异常,HTTP 请求却失败了,前端显示"未发送"。
  • 错误要分得清。 模型服务不可用、模型服务失败、运行时自身的合同错误、可选能力被拒、第三方不可用、第三方结果未知,各有各的代码,运行时自己的错误不能伪装成服务商的故障。

这份方案的方向后来大部分都保留了下来。但真正开始改的时候,冒出来一个 503。

一个 503,和一次被打断的修法

我让 Agent 去查这个 503。查出来的因果链是这样的:用户第一句话里如果带了"评估"之类的词,运行时会把整轮判定成"用户在找第三方支持",换一套完全不同的问题模板去问模型,而那套模板里根本没有记录事实的字段,所以不管用户这一轮说了多少(追尾、地点、有没有人受伤),都存不下来。到了第二轮,运行时还是不知道险种,又走进另一条分支:不调模型,直接拼一句写死的回复,而这句回复缺了审计要求必须有的运行记录,于是 503。

Agent 给的方案是四处局部补丁。我没有采纳,原话是这样说的:

我给你提供文档和 codebase 并非是要限制你的思考方向,只是让你看清楚缺陷,以及尝试通过你自己的独立测试(例如 chromium)复现出更深层的逻辑漏洞。进一步来说,我虽然能用自然语言描述我要的系统最终形态,但是由于代码一直被 gpt 接管,我不知道有没有一个健壮的用户旅程状态机存在,以及这个状态机的设计到底符不符合我的设计思想。这就像做一道鱼香肉丝,先前一直在研究配料和配菜、或者火候什么的乱七八糟的,但实际上用肉应该用的是过油的梅花肉而不是裹了淀粉的后腿肉,你能理解吗?我要的是一个老师。

这段话有两层要求。一是不要只读代码下结论,要自己用浏览器去复现更深的问题;二是我真正想知道的,是系统里有没有一套靠得住的、代表"客户经理此刻该做什么"的判断框架,这比修哪一个报错重要得多。

审计:肉选错了

为了回答这个问题,Agent 组织了一次 29 个子 Agent 的对抗式审计,读代码、交叉验证,前后烧了大约 372 万 token。三个不同视角的诊断者(产品体验、形式化状态机、接手的工程师)各自独立得出了同一个判定:状态机存在,形状不对。8 条关键结论经过反驳,6 条被核实,没有一条被推翻。

我把自己想要的报案过程拆成六点,审计逐条对照了代码实际在做什么:

我要的代码在做的
听懂、记下模型确实在抽字段,但一条事实只有在模型逐字引用用户原话、而且值是引文的子串时才算"确认"。模型把"昨天下午"整理成一个日期,就变成"待确认",系统又把"待确认"当成"缺失"
表单像树一样长树有骨架、没有叶子。14 个分支节点、激活逻辑、跨轮生命周期都在跑,但字段注册表只有 22 个字段,没有一个是分支专属的,11 个条件分支里 6 个字段集是空的
记住、不重复"记住"的判据是字段状态为已确认,而模型的理解落不到已确认,所以只有逐字复述才算记住
每次回复末尾主动追问有追问,但问句是 14 句写死的英文;运行时会删掉模型回复里所有带问号的句子,再从一张固定顺序表里拼一句上去。只要本轮有一条"待确认",就既不追问、也不计问题数
有判断力没有主人。这是哪类险、这轮算什么任务、要不要转人工、递哪张服务卡片、能不能查保单,全由三四套互不同步的正则决定。模型的动作永远是 UPDATE,对案件状态的修改永远是空列表,它挑的服务被丢掉只记一条日志
交出一份干净的报案单立案门槛只检查 6 个公共必填和险种的 1 到 4 个必填,受伤、盗窃、警方都不增加必填

更扎心的一条是受伤。用户一提到受伤,正则命中,立刻转人工,连"有人受伤"这个布尔值都不记,然后 HTTP 层直接短路:此后用户发什么都收不到任何回复,直到员工在工作台上解锁。测试里还专门断言了这个行为。

审计也查清了 502 和 503 的根因,两个服务商是两种病。Gemini 那边,普通报案配置的输出上限是 180 token,而用仓库自己的估算函数算,一个最小的合规响应就要 143 token,带一个字段变更就超了,三次失败的输出 163 到 165 token,恰好是第一个字段写到一半。GPT 那边,为了严格结构化输出,字段变更被改写成"父对象要求恰好 8 个键、每个 oneOf 变体又要求恰好 2 个键",没有任何对象能同时满足,服务商在生成之前就以 400 拒绝了。503 则是确定性分支产出了动作代码,却没有产出运行记录,审计门本身是对的。

审计报告最后用回了我的比喻:肉选错了,跟火候无关。这套系统真正的"肉"是一件事,下一步该问什么、往哪走,由谁决定、凭什么决定。我心里的答案是一位看得见整张案卷和整棵树的客户经理;代码里的答案是一个无状态函数,每轮把整张表从头算一遍,从写死的 14 项顺序表里挑第一个没确认的字段。模型在旁边只做抄写和客套。

但审计同样清楚地说了,配菜切得不错,不用推翻重来:每个字段带来源、状态、引用和断言链的事实账本,是整个系统唯一单一、显式、可枚举的状态空间;"模型提议、运行时落库"的原则,乐观锁和幂等重放,都扎实;按已确认事实推导必填集合、不拿游标在树上走,天然支持用户乱序说话;安全拦截放在模型之前,位置对;第三方服务的注册表、卡片绑在回复下方、同意单独记账,也都对。

所以要动的是"肉",三刀必须一起下:给树加叶子(分支专属字段,分支激活改变必填集合);给模型一条统一的提议渠道(同一轮里可以同时带事实、下一问、服务卡片、状态提议和升级);放宽"什么算记住了"(引文在用户原话里就算,模型的整理不再降级;真正需要确认的只有推断出来的、有争议的、影响权限的事实)。只下一刀都不行:只加叶子,模型还是只能抄写;只给提议权,它面对的还是一张 22 字段的平表。

我认可了这个方向,新开了 issue #980 跟踪重写,还把处理模型调用失败后恢复规则的 #779 并进同一个 PR。我的决定是一次完整实现,不分期。

十四轮补丁

接下来是一份设计规格,从 v2 一路改到 v3.15,十四轮,历时约二十七个小时,这段时间改的全是设计文档,没动一行代码。每一轮几乎都是同一个模式:实现方的 Agent(Codex)照着规格写代码或做验收,发现一个具体矛盾,停下来提问;设计方核实,打一个补丁,版本号加一。问答一路编到了 Q67。

这些补丁里,有几次是我当场把方向扳回来的。

超时。 有一版规定模型调用 45 秒超时,触发后走恢复路径,提示用户重试。我否了:"agent 的任务就是处理用户需求然后给予回应,任何时候都不应该因为模型本身的限制造成回复失败,你在想什么呢?我现在反而怀疑是我们设定的规则反过来限制了模型表现。"核查下来,服务商调用是非流式的阻塞请求,根本分不清"模型还在正常生成、只是慢"和"服务商真的挂了";45 秒这个数字也没有从最坏情况推算过,只是凭"体验要流畅"的感觉定的。最后的规则是收紧每个字段允许写的长度,按新长度重算预算,超时和恢复只留给服务商真正的故障。

思考模式。 我追问能不能关掉模型的思考:任务本身用 GPT-4 或 Claude 3 这一级就够了,我们用更新的模型只是因为当时能访问到的最早版本就是这些,"之前一句简单的问题都要等模型 80 多秒,这非常不合理"。查下来,运行时里早就有关闭思考的开关,但只接给了本地部署的那个模型,两个云端模型都没接,每次调用都在为看不见的思考 token 等待。

险种判断器是正则。 我测试时发现"I was rear-ended yesterday"首轮没有加载车辆分支,问了一句:"什么叫做漏了?我们的统一险种解析器难道是正则?这也太离谱了吧?"Agent 第一次给的修法是"单一候选也能点亮分支",我打了回去:"你这个解法解决当前 agent 只靠正则而没靠自然语言理解的问题了吗?我觉得 runtime 的正则降级为建议层是对的,但是允许 agent 通过理解来判断事故类型(仅允许判断最初的 family 子分支,我们后面的逻辑已经修缮的不错了)……我要的是能代表咱俩观点的解决方案,不是一堆技术描述。"最后就按这个方向定了:险种未确认时,三个险种的专属字段对模型统一开放,由模型凭理解判断;正则退到给追问排序当参考、给无模型的路径兜底。

服务目录。 接着又牵出第三方服务推荐也依赖同一套正则。实现方提议先给模型看一份不分险种的"候选目录",模型判断出险种后再重新过滤。我反问:我们不是已经不让前端逼用户看服务弹窗了吗?候选服务接到后端的分支逻辑就行,它本来就是一个工具箱,要哪个调哪个,用不着一个受动态表单驱动的列表。核实以后,这比"两阶段候选目录"更彻底:服务建议能不能变成卡片,挪到校验阶段、用本轮实际被采纳的险种去查表,不再新造一个中间状态。

还有几次是实现方在验收里抓出来的,都很有代表性:

  • 用户说"Yes we can still live here",字面上只回答了"房子能不能住"一项,模型却把"确认整批"的布尔值填成了真,于是用户从没确认过的险种、受损区域也被一并确认了。运行时其余四个条件都是自己核算的事实,唯独"是否真的执行确认"交给了模型自己填的字段。
  • 模型把用户没提到的地址填成了 >UNKNOWN<,一个非空字符串,于是被当成"已收集、待确认"推进了批量确认,用户会被要求确认一句自己从没说过的话。
  • 险种只有"待确认"时,同一个字段又被算作"缺失",下一轮重复问险种;而批量确认又要求险种先确认。两条规则互相锁死。
  • 用户明确说有人受伤时,如果模型调用超时,流程只保存了消息,却没有创建人工交接,因为创建交接的动作排在"模型成功之后"。运行时已经确定检测到的安全信号,因为模型失败就没落实。

每一条都是真问题,每一条也都修了。

七个症状

十四轮补丁快收尾时,我自己拿真实浏览器把新版本从头走了一遍,然后把症状列给了 Agent:

  1. 第一句就说了车被追尾、天黑、不确定车况,Agent 还在问"能不能描述一下事发经过";
  2. 第一句说了不确定车况,第二次回复又追问同一件事;
  3. 用户回了一句"yeah",模型编出"已记录存在受伤或危险";
  4. 车主被问到"什么东西被盗了";
  5. 内部字段码原样出现在对话里,比如 claim.product_family: motor;
  6. 十一项必填全部完成后,又冒出一句伤情追问;
  7. 该出现第三方服务卡片的时候,卡片没出现。

最后一句是:"流程全乱套了。现在的规则不仅没能帮助模型更加集中和高效,反而在让它胡言乱语。"我追问的是:"为什么不一开始就思考自然交互体验,而是为 runtime 层层设限?"

设计方承认了:从 v2 到 v3.15,每一次改动都是"实现方报错 X,运行时就拦住 X",没有一段写过"一次好的报案是什么样"。十四轮补丁全在改进"运行时怎样核实一件事",没有一轮在问"这件事该由谁来判断"。而且它把"模型提议、运行时决定"这条原则从事实层推到了对话层:替模型选下一问、替模型写汇总、规定每一轮必须有一问。那已经是代笔了,算不上"决定"。

先说清楚三件事

我没有直接让它出新方案。光看改进方案,我判断不了换完以后会是什么效果,得先确认我们对几个基本概念的理解是一样的。我的要求是:

你先给我用简明通俗的人话完整复述一遍用户旅程、用户旅程的状态机和 dynamic form 这三者,以及这三者分别与 runtime 的关系。注意,你要说的是 3×2=6 件事。

复述完、对齐以后,我又加了一句:"这六条先给我记下来,记在一个 journey-soul.md,然后你就可以诊断缺陷和筹备重写方案了,记住,不要给我谈技术。但要是你最终没能实现,也不要给我说纯理念性的东西太玄乎。"

这份文件后来只有一页多,我把它的六条完整转述在这里。

用户旅程

用户视角的一段经历。张三傍晚在 Symonds Street 被追尾,打开 App 跟一位客户经理聊。他说了事情,客户经理记下、问了一两句、给了他一张评估卡;他说"天黑不知道车什么样",客户经理说"没事,评估师看了会告诉我们,你要是拍到照片也可以发我";他说"就这样吧",客户经理念了一遍要交上去的内容,他说"对",报案就交给了理赔团队。三天后他拍了照片回来,客户经理第一句就是"你的车况照片收到了,我补进去"。从头到尾,他感觉是在跟一个懂行的人说话,没觉得自己在填表。

文件是这段经历里随时会出现的东西,不只是照片:警方的报案证明、修车厂的报价单、医院的诊断证明、被盗物品的发票都可能出现。出现的方式有三种,客户经理都要接得住:用户主动发来,甚至一个字不说只丢一个文件,客户经理也要认出它是什么、对应哪条报案;客户经理请用户提供;客户经理知道这份文件该由第三方出,主动去要,回来后自动归位。用户不该被问"你有没有警方证明"这种他不知道该不该有的问题。

和运行时的关系:运行时对用户是隐形的。 用户永远不该看到字段码、不该被固定模板问句、不该被问同一件事两次。运行时唯一合法露面的时刻,是它出于安全或权限拦住了什么。除此之外,凡是用户能感觉到运行时的地方,都是设计的失败。

用户旅程的状态机

客户经理脑子里那套"现在处于什么阶段、这一轮该做什么"的判断框架。它算不上"第一步问什么、第二步问什么"的流程图,更像一个响应框架:每收到一句话或一个文件,客户经理判断三件事,这告诉了我什么、现在处在哪个阶段、这一轮最该做的一个动作是什么。动作只有几种:记下并顺手问一件事、澄清一个矛盾、递一张名片、请用户提供一份文件、帮用户向第三方要一份文件、做一次汇总请他过目、提议提交、转人工,或者只是回答他的问题。阶段大致是了解发生了什么、补齐能补的、该找外部帮手的都安排上、汇总提交、提交后随时可以回来补。首次报案、三天后回来补照片、员工退回来追问一句,用的都是这同一个框架。

和运行时的关系:模型决定"做什么",运行时决定"能不能做"和"怎么做成"。 运行时管这个动作有没有资格发生(用户说"提交",是不是已经到了能提交的阶段;模型说"我来安排拖车",有没有用户授权),落实动作的后果(真的立案、真的派单、真的建交接),以及安全底线(用户说有人受伤,不管模型这轮想干什么,运行时先建交接)。它不生成对话内容、不决定阶段往哪走;替模型选下一问、替模型写汇总、规定每轮必有一问,都是越界。

动态表单

最终交给员工的那张理赔表,只是它跟着对话长出来。一开始只有几个所有理赔都要的公共栏,用户说"车被追尾",长出车险那几栏,说"乘客受伤了",再长出受伤那几栏。永远只长这次报案需要的栏。每一栏最终有四种归宿:填好了;用户说了个大概,够了,不追精度;现在不知道、以后会知道,标"待补";用户根本不该被问,交给第三方回填。

表上也有文件栏,同样走这四种归宿。一个文件到了,不只是填一格:客户经理要看出它能不能顺手补别的格,也要看出它和用户嘴上说的对不对得上。从文件里读出来的值是客户经理的解读,从"待确认"起步。

一栏"未知"算不上缺陷,它是这张表的合法内容之一。 员工看到"车况:待评估师报告",就知道下一步干什么。在提交之前,一栏还会经过空、待确认、有争议这些中间态,中间态到提交时也不阻塞,按"员工能接手"的标准带着标记交过去。每一栏都要按"它是表的一栏"来设计,不能按"它是一道准入门禁"来设计。

和运行时的关系:表长在运行时手里,填表的手是模型。 分支规则是运行时的注册表,是产品设计定死的骨架,模型不能自己发明一栏;每一栏填什么、谁说的、有没有原话支撑、是模糊还是未知,由模型从对话里提出,运行时核实来源后落账。运行时把这张表完整地呈现给模型,但不根据表决定下一句问什么,那是看着表的客户经理的事。

怎么用这份文件

三者串起来是这样:对话推动表长出来,表的状态告诉客户经理现在处在哪个阶段,阶段决定这一轮做什么,动作又产生新的对话。运行时在三者之下托着,守住骨架和事实,执行不可逆的动作,保证中断后一切都还在,但从不开口说话,也不替客户经理拿主意。

文件最后留了四个检验问题,对照任何一条已有或新提的规则:

  • 它让用户感觉到运行时了吗?违反第一条。
  • 它在替模型决定"这一轮做什么"吗?违反第二条。
  • 它把"未知"当成缺陷,或者按门禁的思路、没按表格的一栏来设计字段吗?违反第三条。
  • 它让用户自己去猜"该不该有这份文件",或者让一个主动发来的文件没人认得吗?违反第一条和第三条。

都过,才是合法的规则。之后这份文件又按我的要求补了两次:画成带箭头的流程图,标清每条箭头上是什么在驱动分支;把文件作为和对话文字同等地位的内容,纳入三条路径。

写这份文件的过程中还发生过一件事。一位队友提了 10 条"矛盾",Agent 一度全部采纳,并开始往实现里加分层。我纠正它:"要严格按设计理念,不是修修补补。"最后回滚了其中 5 条,只留下 3 条图里确实画错的和 2 条措辞。

照着六条重写

v4 的结构换了一个顺序:先写三段目标旅程的脚本(车险追尾、房屋水管爆裂、财产被盗时一上来只丢一份警方证明),作为裁判;然后才是宪章(模型九种动作的决定权,运行时管的四件事,明确不做的清单);再把 v3.15 的十五条不变量和全部章节按"留、改、降为观测、删"逐条分类;然后才是表、每轮流程、提交、构建顺序和验收。验收以 Symonds Street 的八轮对话逐轮断言。

几处具体的改法:

"填不上"终于有了表达方式。 领域模型里本来就定义了七种字段状态:填好了、疑似、确认了、有争议、说了不知道、等第三方填、被替换掉。对话逻辑那一层自己把它压成了"填了、没填、够了"三种,把"说了不知道"这个合法状态压没了,模型被逼着编假答案去填格子,>UNKNOWN< 就是这么来的。v4 恢复了七态,并给模型的提议加了一个 disposition:value(有确定答案)、claimant_will_supply(用户以后会补)、third_party_pending(该由第三方填)。我的约束是"基于当前的 codebase 修改或扩展,不要自己发明字段",所以没有新建任何枚举,全部复用已有的状态。

运行时不再开口。 旧代码里有好几处运行时自己拼话给用户:追问失败时换成一句固定英文;判定和模型的回复有冲突时整句替换;问题超了预算就插一句固定收尾。这些全部清掉,措辞交还给模型,并加了一条自动检查,确认正常流程里运行时不生成任何用户能看到的文字。

说的和记的要对得上。 既然措辞全交给模型,就要防止它嘴上说的和账上记的不一致,比如运行时刚拒绝了某项服务,模型却说"我已经帮你安排好了拖车"。新增的核验检查回复里有没有提到刚被拒绝的服务、有没有声称一件没被授权的事已经办成;不过,就把运行时的裁决发回去,让模型重写一次。这里用"纠正性重调"代替了旧做法里的整句剥除。

提交就是"员工能接手"。 唯一阻塞提交的是"没有归宿的空格",待确认、有争议的字段可以带着标记随案件提交;提交以后再补的东西是附在已提交报案上的补充记录,不改写已经交上去的内容。用户不需要知道这个区别,员工需要。

设计文档只写现在该怎么做。 这是重写过程中我给的一条写作规矩:"你别总是往设计文档里加过程性内容了。"从那以后,"v2.3 发现了什么、谁否定过什么"全部搬进单独的变更记录,设计文档本身只陈述当前的规则,实施者不需要读变更记录。

这份六条的文件定下来以后,成了后续所有代码改动的裁判。连代码里的注释都会直接写"这是按 journey-soul 的设计",用来说明一处行为是故意的,没有漏掉。

合并和审查

真正开始写代码,是在我要求设计方"这次要由你亲自下场"之后。改完要合回 main 时,main 上已经有几位队友并行推进了三个方向部分重叠的功能:付款联系人流程、消息重复提交的修复、立案的防重复保护。

合并里最重要的一次判断,是人工交接期间该不该继续和用户说话。我们这边的设计是交接期间模型照常被调用,只是不再推荐第三方服务,代码注释写着"交接改变了负责人,但不该让对话变哑";main 上队友独立完成的设计正好相反,交接期间用户的新消息只记录、不调用模型、不生成回复。我一开始以为交接只指"案件已经立案、员工接手"的场景,查清楚以后发现它的范围更广,用户要求人工、无障碍需求、情绪痛苦、紧急安全风险都算。弄清范围后,我决定完全遵循 main 的静默设计。几小时后又牵出一个边界:一条之前因为网络问题没走完的消息,用户重发一次,算不算交接期间的新消息?我的判断是静默只挡真正的新内容,重试一条被打断的旧请求照常走完。

自动合并还藏了两类问题,都是"看起来没有冲突,实际选错了一边":一个前端功能被悄悄丢掉了,没报冲突;一套本该淘汰的旧处理逻辑被复活成了一个同名的重复定义,运行时会不声不响地用后加载的旧版本盖掉新版本。都是靠逐行比对代码发现的。

CI 基本转绿以后,来了一份正式的代码审查报告,列了五个阻塞级问题。我的指令是:"先核实,并定位核心问题,再说结论。最后附上改法,只检查,不碰代码。"确认属实后才授权修,并且"无论如何都不要破坏已跑通的消息处理逻辑"。五个问题里有三个,是同一条原则在不同代码路径上没落实全:

  • 模型标了"用户以后会补"、值是空的,运行时在这条路径上完全没做原话核实,模型可以凭空宣称"用户说了不知道"。前面已经在另一条路径上修过"标签不能替代核实",改一处不代表整条原则都生效了。
  • 回复核验里有一条豁免,允许模型说"我会把报案提交给理赔团队",因为这是它份内的事。可豁免写得太宽,"我会提交"和"我已经提交了"一律放过,模型真的可以谎称已经提交。修法是把将来时和完成时分开,只豁免前者。
  • 一个"该由第三方填"的标签,运行时只检查了这项服务这一轮能不能被提议,没检查用户是不是真的同意过它介入。

另外两个:一个是重新打开报案单时,用户以前发过的证据文件被整批塞进当前的输入框;另一个是合并时我们自己引入的回归,整段替换成 main 的版本时,删掉了旧版本里区分"这是一条错误记录"和"这是一次成功响应"的一小段判断,本该返回冲突的情况变成了返回"找不到"。这一条的教训很直接:合并冲突时整段替换是有代价的,不逐行比对,就会悄无声息地丢掉一段专门处理边界情况的代码。

花了多少

这个 PR 加了 14,482 行、删了 2,079 行,跨了大约五天。第一次审计烧了约 372 万 token;设计文档打补丁打了十四轮,约二十七个小时,没碰代码;判定补丁路线失败后从头重写,落地拆成十步;合并经历了两轮冲突处理,外部审查检出五个阻塞问题;本地跑过的全量测试从两千九百多条到三千零三十一条。开发环境还换过两次模型:一次从大模型换到更轻的云端模型,吞吐提升三倍多;一次在发现轻模型对好几个明显能判断的字段直接贴"以后再说"敷衍之后,只换了模型大小、没换服务商,好一次性确认问题确实出在模型能力上。最终这个 PR 关闭了 #976 和 #980,也处理了 #779 的一部分。

回到那道鱼香肉丝

这件事留给我的,是一个做判断的顺序。设计决定一条条叠上去,不会自然形成好的体验;十四轮补丁每一轮都合理,叠在一起却让模型胡言乱语。补丁修的都是"运行时怎样核实一件事",可真正出问题的,是"这件事该由谁来判断"。所以重写的时候,我们先定义一次好的报案是什么样,再倒推需要哪些设计决定,之后每一个设计决定都要能说出它服务于六条里的哪一条,说不出来的就不该存在。