那批报销单卡了两周,没人发现顺序错了
需求特别老实:一张报销单走三道审批,主管、财务、合规,谁先谁后制度文件里写得明明白白。我觉得这活太直白,固定版本写完手又痒,改成让模型每轮自己挑下一个该找谁。当时给自己找的理由是"以后顺序可能会调整,这样不用改代码"。
演示跑了两回都挺像样,真上线跑了两周,四十多单积压在"合规已经退回、主管还没看过"这个位置。没人报错。每个节点都返回"成功",模型每轮也确实挑了一个还没审过的角色,从日志看一步都不冤枉。错的是顺序本身。合规看了一张财务还没核过数的单子,回了句"材料不足,退回"。退回件就在系统里躺着,两周没人碰,直到有人按创建时间排一次序才发现不对。
后来我把它改回固定顺序,代码比原来的版本还短。这个弯路的代价我记到现在,而"路径谁来定"从那天起变成我评审时的必问项。
先把两个词分开说
固定流程编排(workflow)是路径写死在代码里,节点之间怎么走,你提前就定了。自主循环(agent loop)是路径由模型现场决定,每一轮看前面的结果,自己挑下一步。这两样常被混着叫 agent,我觉得这是本项目里最容易害人的一个说法。
| 比什么 | 固定流程编排 | 自主循环 |
|---|---|---|
| 谁定下一步 | 你的代码 | 模型每轮现选 |
| 出错怎么暴露 | 某个节点报错,位置明确 | 每步都"成功",只有结果不对 |
| 需求变了 | 改代码、走发版 | 改提示词,可能不用发版 |
| 用得上它的前提 | 路径事先画得出 | 路径事先画不出 |
这张表里最要紧的是前提那一行。这两种形态不是新旧关系,是分工关系。我的默认答案偏向固定流程,除非那张图真的画不出来。
分界线就一句话
把流程图交给一个刚入职的同事,他不查模型、不问任何人,能不能说出下一步该找谁?能说出来,这路径就是确定的,别让它现场发明。
这句问法我用到现在,替我挡掉过不少活。有一次是"合同关键条款抽取",从头到尾一条道:拿文本、抽取、校验、写库,中间没有岔路。这类活我也见过有人给它加循环,加完只多两件事——多烧 token,多一种没人解释得清的错。另一次是"客户投诉分流"。画图的人自己承认,三条分支之外还有一条叫"看情况"。那就是画不出唯一路径,该给循环,我没拦。
顺便交代一个骗自己的地方。"以后规则会变"不是让它现在发明的理由:顺序会变,那是配置的事,写在配置里读出来,仍然不需要模型每轮投票决定。我当初就是拿这句话给自己搭的台阶,爬上去才发现下面没路。
三种看着要自主、其实画得出路径的活
回看那些我说过"这活得用循环"的场合,三类最多。客服类的"多轮对话",听着就自主,实际拆下来是:识别意图、按意图查订单、拿结果套话术答复、该转人工就转。岔路只有几条,全画得出。写报告类的"自动生成周报",取数据、填模板、润色,一条道走到底。中间没有一步要靠上一步的结果决定下一步去哪。还有一类是"跑测试并修掉失败用例",这个我真给循环。改到什么程度算改好,事先没人知道,而每轮的下一步取决于上一轮吐回来的失败列表。三类里我只在"跑测试并修掉失败用例"那类上松手。
判断方法很土,我每次都当面对需求方做:拿张纸,让他把活的走向画出来,不许说"看情况",然后数岔路数量。三条以内的岔路,我一律按固定流程写;超过三条,或者他画到一半开始解释"这个得看是谁提的",那就先停下来谈需求。谈不出唯一路径不是循环的理由,是没定义完。
我也干过反向的蠢事。有阵子我把"排查线上接口报错"写成固定流程,跑到第三个节点就没路可走了。日志里可能是超时,可能是脏数据,也可能上游改了字段——每个方向后面要做的动作完全不同。我硬加了七个分支去兜,代码长成一片 if,最后照样漏。那次才真把我教会——分支多到需要靠注释解释注释的时候,就别再画了。
同一段业务,写两遍
代码放一起看更直观。固定走向那半段,模型只在节点里做单轮判断;自主那半段,连"下一个找谁"都交给它。
APPROVAL_ORDER = ["主管", "财务", "合规"] # 制度文件里写死的顺序
def run_fixed(ticket)
"""路径由代码定,模型只填每个节点上要判断的空。"""
trail = []
for role in APPROVAL_ORDER
v = ask_model(
f"金额 {ticket['amount']} 元、类目 {ticket['category']},"
f"站在 {role} 的角度,通过还是驳回?只回 JSON。"
)
trail.append({"role": role, **v})
if v["verdict"] != "pass"
break # 终止位置是确定的,一眼看得出来
return trail
def run_open(ticket)
"""路径交给模型:它每轮自己挑下一个还没审的角色。"""
trail, left = [], set(APPROVAL_ORDER)
while left
role = ask_model(
f"已审完 {[t['role'] for t in trail]},"
f"还剩 {sorted(left)},下一个该找谁?只回角色名。"
)["next"]
if role not in left: # 固定版本里这叫 bug,在循环里这叫模型行为
raise ValueError(f"不认识的角色:{role}")
left.discard(role)
trail.append({"role": role, **ask_gate(role, ticket)})
return trail
两段的全部差别在 for 和 while 这一个字母上。for 遍历的是你写下的列表,while 遍历的是模型当天的判断。
写注释那会儿我才想清楚一件事。run_fixed 里角色名拼错会当场炸;run_open 里模型挑了个"看着合理但制度不允许"的顺序,什么异常都不抛。故障不会不来,它只是换了一副更贵的面孔来。
让模型挑顺序之后,故障长什么样
自主循环最难看的毛病不是成功率低,是失败不发出声音。那两周我盯过日志:每轮都有输出,每一步都被系统记成"已完成",看板上一片绿。要判断顺序对不对,只能把整条轨迹重放一遍,用眼睛数三个角色的先后。两周我看了不到十条——真没人有那个功夫。
那事最后是怎么暴露的?一个同事做季度统计,按创建时间排了一次序,发现退回件的时间戳全挤在中间。换句话说,发现事故靠的不是监控,是有人恰好在两周后做了件无关的事。这类运气我不打算再赌。
固定流程不一样,它坏在一个具体的地方。财务节点接口超时,报出来的就是财务节点超时。值班的人三句话能讲清事故,还能把当天卡在同一节点的单据一次全捞出来。这两种"坏了"的差别,在运维上的价值比准确率大得多。我现在敢说的取舍是:宁可准确率少一个点,也要换回一个能报错的位置。
路径写死还有一层好处,我起初没意识到:责任清楚。制度改了顺序,改动留在代码评审的记录里,谁改的、哪天改的、为什么改,一句话能说完。路径交给模型以后,出了事没人说得清这个决定是谁做的——模型不算责任人,写提示词的人当时也没想到它会这么排。
顺着这个思路,我给自主循环定的入场条件不是"它更聪明",而是失败必须自带声音。超轮数要报,无进展要报,工具连着同类错误要报。这三种信号我做不出来的时候,我承认自己其实没法维护它。
(这段数字是我自己项目的体感,不是测出来的对照组。)
也有它该上场的时候
上一篇那三件事全过之后,循环确实有活干,我不拦。这里只补一条从代码才看得出来的判据。如果你的循环里,while 什么时候退出、下一轮选谁,这两件事都能穷举列出来,那它其实就是画得出路径的活。写成 for 会诚实得多。
报销那活还说明了另一件事:大多数真实需求不是二选一,而是落在中间。"这张单子该通过还是驳回"确实需要模型判断,金额、类目和制度条款得对着看;"下一个找谁"不需要。把模型放进节点里,而不是放进路径里,是我这两年做得最值的习惯。
节点里用模型,我有三条硬约束。要结构化输出,只回 JSON 或者枚举值,别让它写散文;写完立刻校验,校验不过就当节点失败,不许它自己解释一遍再试;一个节点最多问一次,问不出结果就停在这个节点报错。这三条看着小,能挡掉大部分"模型顺手多做了一点"的事故。顺序本身挪进配置,需求方改顺序时改配置,代码不动——这样既不用发版,也不用让模型投票。
我现在的默认答案
新需求我按固定流程起步,模型只在节点里做单轮判断。跑上两周,如果真冒出事先没想到的分支,再考虑把其中一段换成循环。这个顺序不能反着来:先上循环再往回收,你会分不清"它真的需要自主"还是"我没把需求想清楚"。
有件事我还没找到好办法。固定流程对可预测性的好处,恰好也是它招人烦的地方。需求方嫌改一次顺序要走发版,而自主循环只要改一句提示词。我目前的解法是把顺序挪进配置文件,改配置不用发版。可这条路只解决"顺序可变",一旦分支结构本身要变,还得回到会议室里吵。如果你的分支能在运行时安全重排,我很想知道你拿什么兜住责任。