02|流程能画死的活,别让模型去挑下一步

0 阅读9分钟

那批报销单卡了两周,没人发现顺序错了

需求特别老实:一张报销单走三道审批,主管、财务、合规,谁先谁后制度文件里写得明明白白。我觉得这活太直白,固定版本写完手又痒,改成让模型每轮自己挑下一个该找谁。当时给自己找的理由是"以后顺序可能会调整,这样不用改代码"。

演示跑了两回都挺像样,真上线跑了两周,四十多单积压在"合规已经退回、主管还没看过"这个位置。没人报错。每个节点都返回"成功",模型每轮也确实挑了一个还没审过的角色,从日志看一步都不冤枉。错的是顺序本身。合规看了一张财务还没核过数的单子,回了句"材料不足,退回"。退回件就在系统里躺着,两周没人碰,直到有人按创建时间排一次序才发现不对。

后来我把它改回固定顺序,代码比原来的版本还短。这个弯路的代价我记到现在,而"路径谁来定"从那天起变成我评审时的必问项。

先把两个词分开说

固定流程编排(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 或者枚举值,别让它写散文;写完立刻校验,校验不过就当节点失败,不许它自己解释一遍再试;一个节点最多问一次,问不出结果就停在这个节点报错。这三条看着小,能挡掉大部分"模型顺手多做了一点"的事故。顺序本身挪进配置,需求方改顺序时改配置,代码不动——这样既不用发版,也不用让模型投票。

我现在的默认答案

新需求我按固定流程起步,模型只在节点里做单轮判断。跑上两周,如果真冒出事先没想到的分支,再考虑把其中一段换成循环。这个顺序不能反着来:先上循环再往回收,你会分不清"它真的需要自主"还是"我没把需求想清楚"。

有件事我还没找到好办法。固定流程对可预测性的好处,恰好也是它招人烦的地方。需求方嫌改一次顺序要走发版,而自主循环只要改一句提示词。我目前的解法是把顺序挪进配置文件,改配置不用发版。可这条路只解决"顺序可变",一旦分支结构本身要变,还得回到会议室里吵。如果你的分支能在运行时安全重排,我很想知道你拿什么兜住责任。