张彦峰ZYF头像
Agent 确定性优先:什么时候用 DAG,什么时候让模型决策封面图

Agent 确定性优先:什么时候用 DAG,什么时候让模型决策

目录

一、同一个需求的两次失败

(一)第一次:把所有判断都交给模型

(二)第二次:把所有路径都写成固定图

(三)两次失败的共同点

二、把一次任务里的「决策」拆成五类

(一)五类决策

(二)一个反直觉的观察

(三)拆开之后,问题就变小了

三、归属判据:错误代价 × 可检出性

(一)四个象限的处置方式

(二)两个容易画错的地方

1、把「检出难度」当成了「监控有没有报警」

2、把代价按「技术影响」估,而不是按「业务与合规影响」估

(三)怎么把这张图真正用起来

四、确定性优先原则

(一)原则与三条推论

1、模型的输出应当是「提议」,不是「指令」

2、自主必须是有界的

3、确定性要能被观测

(二)这条原则什么时候不成立

五、四档控制强度

(一)四档的定义

1、固定编排

2、受约束选择

3、有界自主

4、开放自主

(二)为什么是四档而不是两档

(三)档位不是越低越好

(四)四档的九维对照

六、在一条任务链上混用四档

(一)一条真实形状的任务链

(二)这条链的关键性质

(三)分段之后的三个好处

(四)一条经验

七、核心实现

(一)控制权与信任边界

(二)档位与裁决的基本结构

(三)受约束选择:把输出关进封闭集合

1、候选集合按权限动态生成

2、模型异常与非法输出走同一条路径

3、重试有上限,且每次都预扣预算

4、非法输出也写进轨迹

(四)有界自主:四道闸

(五)降档:第二条路

(六)运行输出

八、从 Demo 版到生产版

(一)十项差异

(二)这张表的正确用法

(三)关于上线开关

九、失败模式与工程权衡

(一)七类失败模式

(二)一个共同点

(三)三个常见反例

1、「加一个校验的 Agent 就行了」

2、「用更强的模型就不会走错路了」

3、「先上线,监控发现问题再改」

(四)核心权衡:可预测性与覆盖率

十、三层验证

(一)第一层:路径一致性测试

1、路径指纹怎么算

2、通过标准

(二)第二层:决策点级评测

1、通过标准的设计

2、闸门要能单独测

(三)第三层:端到端与失败注入

(四)三层之外还要补的一件事

十一、安全、性能与成本影响

(一)安全

(二)性能

(三)成本

(四)九个上线指标

十二、生产检查清单

(一)设计阶段

(二)实现阶段

(三)验证阶段

(四)运行阶段

(五)清单之外的一句话

十三、结论与延伸

(一)本文结论

(二)与专栏其他文章的衔接

参考资料


干货分享,感谢您的阅读!

把一整个系统标记为「确定的」或「自主的」,是大多数编排设计从一开始就走偏的地方。确定性不是系统级开关,而是决策点级别的属性。

这次我们把一次任务里的判断拆成五类决策,给出「错误代价 × 可检出性」的归属判据与四档控制强度,再用一份可运行的混合编排器演示受约束选择、有界自主的四道闸与降档路径,最后给出三层验证方法、九个上线指标与二十项检查清单。

本节,作为读者你获得什么

  • 一条可以直接用在设计评审上的判据:给定一个决策点,判断它该归代码还是归模型,以及归到哪一档。
  • 四档控制强度的定义、九维对照与混用方式——不确定性可以被关进一两个小盒子,而不是弥漫在整条链上。
  • 一份仅依赖标准库、可直接运行的混合编排器实现:受约束选择、四道闸、降档路径、决策轨迹与路径指纹。
  • 五种失败注入下的真实运行输出,以及十四个覆盖三层验证的测试。
  • 七类失败模式的成因与对应改法、Demo 与生产版本的十项差异、九个上线指标与二十项检查清单。

关于内容立场:本文中的业务场景、打分与阈值均为示例,用于说明取舍结构,不是任何系统的实测数据。涉及具体产品或版本时,请以访问当日的官方文档与你自己的验证结果为准。还有设计的代码入如果写的有问题的,请您在评论区留言提醒,感谢!

一、同一个需求的两次失败

(一)第一次:把所有判断都交给模型

一个企业任务执行 Agent 的第一版做法很典型:一个自主循环,工单该归哪一类、要不要查库、要不要建单、要不要给用户发通知,全部由模型在循环里自行决定。演示时效果很好——它能处理没写过规则的组合,看起来「聪明」。

上线两周后出现三类症状。

第一类是路径漂移:同样一句「我的订单一直显示处理中」,两次运行走了不同路径,一次直接查了订单库并回复,一次先检索了知识库、再查库、再建了一张工单。两次结果都不算错,但用户体感完全不同,客服也没法预期系统会做什么。

第二类是步数分布分裂:一半的任务在第三步就结束,另一半绕了九步。九步的那些任务里,有相当一部分最后给出的答案与第三步就能给出的答案没有实质差别。

第三类是无法归因:出问题时没人能回答「这一次为什么走了这条路」。日志里有模型的输出,但没有「当时的候选有哪些、为什么选了这个」。

团队的应对是改提示词。三周内改了七版,每一版都修好一类、弄坏另一类——因为提示词是全局的,而问题是局部的。

根因不在模型能力,而在于:「这个工单该走哪条流程」这种本该确定的路由决策,被交给了模型。

(二)第二次:把所有路径都写成固定图

第二版推翻重做,走到了另一个极端:画一张有向无环图,每个分支条件都写成代码里的 if。上线初期非常稳定,同样的输入永远走同样的路径,出问题也能一眼定位。

两个月后出现另外三类症状。

第一类是分支爆炸:图里有四十一个节点,其中二十九个是为了处理各种条件组合而增加的分支。

第二类是改动放大:新增一种工单类型,要在五处分支里加判断,而这五处分散在三个文件里。

第三类是兜底失灵:用户换一种说法,例如把「订单」说成「我买的那个东西」,就走不进任何分支,统一掉进「无法识别,请重新描述」的兜底分支。这个兜底分支的命中率在两个月里从 3% 涨到了 17%,而它本身没有任何设计——只有一句道歉。

根因同样不在技术选型,而在于:「理解用户到底想做什么」这种本该由模型承担的决策,被硬写成了枚举匹配。

(三)两次失败的共同点

两次讨论的都是同一个问题:「这个系统整体要不要确定」。

但一个真实任务里同时存在两类性质完全相反的决策。一类错了代价很大且不容易被检出,比如该不该调退款接口、该走哪条审批线;另一类错了代价很小且立刻能被看见,比如这段话该怎么措辞、摘要保留哪三条。

把这两类决策绑在同一个开关上,无论开关拨到哪一边都是错的。

本文要做的事情只有一件:把这个开关从系统级拆到决策点级。

二、把一次任务里的「决策」拆成五类

(一)五类决策

路由决策:走哪条分支、调哪一类子流程、要不要进入审批。特征是结果集有限,可以穷举,也可以回归。

参数决策:调用工具时每个字段填什么值、查询范围有多大、时间窗口取多长。特征是可以用 Schema 与业务规则强约束。

内容决策:怎么措辞、摘要保留哪几条、报告怎么组织。特征是没有唯一正解,人也难以穷举。

终止决策:还要不要继续、证据够不够、现在能不能交付。特征是错了会直接变成死循环或过早退出。

异常决策:工具失败之后重试、换路径还是放弃、要不要升级给人。特征是它发生在系统状态最差的时刻,而这恰恰是最需要确定性的时刻。

(二)一个反直觉的观察

多数团队在争论「要不要用 Agent」的时候,脑子里想的其实只有第三类——内容决策。「模型写得比模板好」是真的,也是引入模型最直接的收益。

但生产事故几乎全部来自第一、二、四、五类:路由错了办错事,参数错了扫全表,终止错了烧预算,异常错了把一次抖动放大成一次故障。

而这四类恰恰是最适合用代码约束的。

这个错配解释了一个常见现象:团队为了第三类收益引入了模型,却顺手把第一、二、四、五类也一起交了出去——因为在一个自主循环里,这些决策本来就混在一起,没有被分开命名过。

(三)拆开之后,问题就变小了

拆成五类之后,不需要再回答「这个系统要不要确定」这种无法回答的问题,只需要对每一个决策点回答三个可以回答的问题:

1、它错了,业务代价有多大?

2、它错了,多久能被发现?

3、它的正确答案能不能写成规则?

三个答案决定这个决策点归谁。

三、归属判据:错误代价 × 可检出性

(一)四个象限的处置方式

把上一节的前两个问题画成两个轴,得到四个象限。

代价大、难检出(左上):必须由代码判定,模型最多提建议。典型是调退款、改订单、选择审批链路、放弃任务并归档。这一格的共同点是「错了以后要花很大力气才能还原」。

代价大、易检出(右上):模型可以决策,但必须有硬闸门。典型是要不要继续循环、要不要扩大检索范围、要不要换一个工具。错了会被立刻看见(时延变长、成本变高),所以可以允许模型判断,但上界必须由代码给。

代价小、难检出(左下):模型决策,加抽样复核。典型是检索用了什么查询词、摘要保留了哪几条、报告的章节顺序。单次错误无关紧要,但系统性偏差需要靠抽样发现。

代价小、易检出(右下):交给模型,不必干预。典型是措辞、语气、同义改写、排版格式。

(二)两个容易画错的地方

1、把「检出难度」当成了「监控有没有报警」

正确的口径是:从错误发生到有人知道,要多久。 退款多打一笔,技术上一秒就发生了,但可能一个月后对账才被发现——它属于「难检出」,哪怕你的监控面板上有退款笔数曲线。

2、把代价按「技术影响」估,而不是按「业务与合规影响」估

一次误发短信在技术上毫无影响:没有报错、没有数据不一致、没有资源浪费。但在合规上,它可能是一起需要上报的事件。

代价口径应由业务方确认,不由开发一个人定。这一步花掉的半小时,通常比后面三周的提示词调优更有价值。

(三)怎么把这张图真正用起来

把一次任务里的每一个决策点画上去,注意是按「一次具体判断」画,而不是按「模块」画。「意图理解模块」不是一个决策点;「在八个已注册的任务类型里选一个」才是。

落在左上角的写成代码,落在右下角的交给模型,落在另外两格的用「模型提议 + 代码裁决」。

这张图真正的价值不在分类本身,而在于它逼着团队把每个决策点的代价说清楚——很多设计问题在说清楚代价的那一刻就自动解决了。

四、确定性优先原则

(一)原则与三条推论

原则:对任何一个决策点,默认由代码决定;只有当「写成规则的成本」高于「模型出错的代价」时,才把它交给模型。

换句话说:交给模型是一个需要被论证的例外,而不是默认选项。

由它可以推出三条更具操作性的结论。

1、模型的输出应当是「提议」,不是「指令」

模型给出候选与理由,由代码做最终裁决。这一层转换在实现上几乎不增加成本——无非是把「解析后直接执行」改成「解析后先过校验器」——却把一个不可控的行为变成了一个可审计的行为。

2、自主必须是有界的

允许模型自主,不等于允许它无限次尝试。步数、预算、工具白名单、进展判定,四道闸缺一不可。第五节会逐一展开。

3、确定性要能被观测

「这次为什么走了这条路」必须在轨迹里可查。查不到的确定性等于没有——因为没有人能证明它成立,也没有人能在它失效时发现。

(二)这条原则什么时候不成立

当需求形态本身还没稳定时,过早写死规则会让每次业务变化都变成一次改代码。这时候强行套用「确定性优先」,得到的会是本文开头的第二种失败。

正确的做法是先观察,后固化:先用较宽松的自主跑一段时间,把真实发生的路径统计出来,再把高频路径固化为代码。顺序不能反过来——先固化再补丁,补丁会一直打下去。

判断「是否已经可以固化」有一个粗略的信号:把最近两周的真实任务按路径聚类,如果前五条路径覆盖了 80% 以上的流量,就到了可以固化的时候。这个比例是示例值,具体阈值取决于业务的长尾程度。

五、四档控制强度

(一)四档的定义

1、固定编排

路径由代码完全决定,模型只做内容生成。典型场景:已知类型的工单,查库 → 生成 → 回写三步固定。可预测性最高,覆盖面最窄。

2、受约束选择

模型只在一个有限集合里选择,代码校验后执行。典型场景:从八个已注册的子流程里选一个。保留了模型最有价值的能力——把自由表达映射到业务概念——但结果集是封闭的。

3、有界自主

模型在预算、步数、工具白名单内自由循环。典型场景:多轮检索与交叉验证,最多八步、两万 Token。能处理没有预设过的组合,代价是时延与成本会抖动。

4、开放自主

无固定上界,靠模型自行判断何时停止。典型场景:研究型探索任务,在受控沙箱内运行。覆盖面最大,生产环境几乎不用——不是因为它不好,而是因为它无法给出时延与成本的承诺。

(二)为什么是四档而不是两档

在两档的世界里,只要有一个决策点需要理解自然语言,整条链就会被推到「全自主」那一侧。这是第一种失败最常见的产生方式。

四档让你可以说:这条链上第二步是受约束选择,第四步是有界自主,其余都是固定编排。这样的系统里,不确定性被关在两个小盒子里,而不是弥漫在整条链上。

(三)档位不是越低越好

把所有东西压到第一档,会得到本文开头的第二种失败:分支爆炸与兜底失灵。

判断标准不是「档位低不低」,而是「这个决策点的档位,是不是它需要的最低档」。需要理解自然语言的地方硬压到第一档,省下来的不确定性会以「漏判」的形式还回来——而漏判比走错路更难被发现,因为它表现为「用户说了但系统没反应」。

(四)四档的九维对照

这张表的打分是示例场景下的相对判断,用于说明取舍结构。读它有三个要点。

第一,不要把四列加总排名。九个维度在不同业务里的权重差别极大,加总隐含了「同等重要」这个几乎从不成立的假设。

第二,最后一行才是这张表的用法:先确定这个决策点属于哪一类决策,再回头看它应该落在哪一档。

第三,「失败归因难度」这一列常被忽略,但它决定了上线后半年的运维成本。归因难的档位,事故平均处理时长会显著更长——而这部分成本不会出现在任何一次选型评审的材料里。

六、在一条任务链上混用四档

(一)一条真实形状的任务链

以企业任务执行 Agent 处理一张工单为例(示例场景),六个环节分处不同档位:

环节档位这一段在做什么
接收与校验固定编排租户、权限、配额、幂等键
意图理解受约束选择在八个已注册任务类型中选一个
澄清判定受约束选择信息够不够,不够就问一句
证据收集有界自主检索与查库,八步 / 两万 Token
报告生成内容决策结构由代码给定,措辞由模型写
副作用执行固定编排审批、幂等键、补偿与审计

(二)这条链的关键性质

六个环节里,只有一个处在「有界自主」档。

这意味着:即便模型在证据收集阶段完全失控,它能造成的最坏后果也被限制在「多花两万 Token、多等二十秒」,而不会变成「多打一笔退款」。

副作用执行永远在第一档,这是硬性要求:钱和数据的变更不接受概率性判断。

(三)分段之后的三个好处

第一,每一段可以单独测试。固定段用单元测试,受约束段用枚举回归,自主段用抽样评测。三种方法的成本差了一个数量级,混在一起会被迫全部用最贵的那种。

第二,时延与成本可以按段核算,优化时知道该往哪里投入。绝大多数任务链的时延集中在自主段,但如果不分段统计,很容易把精力花在优化模型调用的并发上。

第三,出问题时先定位到段,再定位到点。这是把平均定位时长从小时级降到分钟级的关键。

(四)一条经验

在动手之前,把任务链画成一排方框,给每个方框标上档位。

如果你发现自己给超过两个方框标了「有界自主」,多半是决策点还没有拆干净——回到第三节的四象限图,把那两段里真正需要模型的部分再分出来。

七、核心实现

本节给出一份可直接运行的混合编排器。运行环境:Python 3.12,仅依赖标准库,无需任何第三方包与网络访问。

source/
├── orchestrator.py    # 编排器:档位分派、四道闸、轨迹与路径指纹
├── fake_model.py      # 可注入故障的假模型
├── demo_task.py       # 六环节任务链(示例场景)
└── test_paths.py      # 三层验证,共 14 个测试

启动方式:

cd source
python3 demo_task.py              # 正常路径
python3 demo_task.py illegal      # 模型返回非法候选
python3 -m unittest test_paths -v # 三层验证

(一)控制权与信任边界

架构图的要点只有一句:控制权始终在编排层手里,模型只在被询问时提供提议。

编排层负责四件事:任务图定义(节点、边、档位标注)、决策点求值(按档位分派到规则或模型)、闸门与预算、状态与轨迹。

模型只接收当前状态的一个切片,返回候选与理由。它不知道有哪些工具可以直接调用,也不知道自己的输出会不会被执行。

信任边界应该画在「模型输出」与「规则校验器」之间。模型的输出在通过校验之前,一律视为不可信数据——它可能来自被污染的检索结果,也可能只是一次普通的格式跑偏。

这条边界带来一个直接结论:任何「模型说要调用某个工具就调用」的实现,都把信任边界画错了位置。

(二)档位与裁决的基本结构

class Level(enum.StrEnum):
    """四档控制强度。档位越低,路径越确定。"""
    FIXED = "固定编排"
    CONSTRAINED = "受约束选择"
    BOUNDED = "有界自主"
    OPEN = "开放自主"


@dataclass
class Verdict:
    """一次裁决的结果,同时也是轨迹的一条记录。"""
    point: str
    level: Level
    candidates: list[str]
    chosen: str | None
    basis: str                      # 规则名,或模型给出的理由
    accepted: bool
    elapsed_ms: float = 0.0

Verdict 的五个字段(决策点、档位、候选、结果、依据)是事后归因的最小集合。缺任何一个,都会在复盘时出现「知道它走错了,但不知道为什么走错」。

落盘位置建议与任务状态同表,这样断点续跑时决策历史不会丢——这一点在长任务场景下尤其重要。

(三)受约束选择:把输出关进封闭集合

这是投入产出比最高的一档,多数「需要理解,但不需要自由」的场景都落在这里。

def constrained(self, point: str, prompt: str, candidates: list[Candidate],
                *, granted: frozenset[str], retries: int = 1,
                tokens_per_call: int = 400) -> str:
    """模型只能在 candidates 里选。非法输出按有限次重试,仍失败则抛出 DecisionError。

    注意:候选集合按权限过滤后再交给模型,避免出现「选了一个没权限的流程」。
    """
    allowed = [c for c in candidates if set(c.requires) <= granted]
    if not allowed:
        raise DecisionError(point, "该租户没有任何可用候选")
    keys = [c.key for c in allowed]
    last = "未调用"
    for attempt in range(retries + 1):
        t0 = time.perf_counter()
        self.budget.reserve(tokens_per_call)
        try:
            raw, reason = self.model.propose(prompt, allowed)
        except Exception as exc:                       # 模型侧异常也算一次失败
            last, raw, reason = f"模型异常:{exc}", "", ""
        else:
            last = reason
        ms = (time.perf_counter() - t0) * 1000
        if raw in keys:
            self._record(Verdict(point, Level.CONSTRAINED, keys, raw, reason, True, ms))
            return raw
        self._gate("invalid_candidate")
        self._record(Verdict(point, Level.CONSTRAINED, keys, raw or None,
                             f"非法候选(第 {attempt + 1} 次)", False, ms))
    raise DecisionError(point, f"{retries + 1} 次均未返回合法候选,最后一次:{last}")

这段代码里有四个刻意的设计。

1、候选集合按权限动态生成

allowed 是按租户授权过滤后的结果,而不是一个写死在提示词里的常量列表。否则会出现「模型选了一个该租户没有权限用的流程」,然后在执行阶段才被拒绝——那时用户已经等了几秒钟,而且错误信息对他毫无意义。

2、模型异常与非法输出走同一条路径

except Exception 之后不重新抛出,而是当成一次「未返回合法候选」。这样上游只需要处理一种失败形态,降档逻辑不必区分「模型挂了」和「模型跑偏了」。

3、重试有上限,且每次都预扣预算

retries 默认是 1,也就是最多两次调用。无上限重试是最常见的成本失控来源之一,因为它在功能上「看起来是对的」。

4、非法输出也写进轨迹

accepted=False 的记录同样落盘。这些记录是「无效输出率」这个指标的唯一数据来源,也是排查模型版本变更影响的第一手材料。

(四)有界自主:四道闸

def bounded(self, point: str, step, *, max_steps: int, stall_limit: int = 2,
            tokens_per_step: int = 800, enough=None) -> BoundedRun:
    """四道闸并联:步数、预算、工具白名单、进展判定。"""
    run = BoundedRun()
    stall = 0
    for i in range(max_steps):
        try:
            self.budget.reserve(tokens_per_step)
        except DecisionError:
            self._gate("budget")
            run.stop_reason, run.complete = "budget", False
            break
        new, tool = step(i, set(run.evidence))
        if tool not in self.readonly_tools:            # 工具闸
            self._gate("tool_denied")
            run.stop_reason, run.complete = "tool_denied", False
            break
        fresh = new - run.evidence
        run.evidence |= new
        run.steps = i + 1
        if enough is not None and enough(run.evidence):
            run.stop_reason, run.complete = "done", True
            break
        if fresh:
            stall = 0
        else:
            stall += 1
            if stall >= stall_limit:                   # 进展闸
                self._gate("no_progress")
                run.stop_reason, run.complete = "no_progress", False
                break
    else:
        if run.steps >= max_steps:                     # 步数闸
            self._gate("max_steps")
            run.stop_reason, run.complete = "max_steps", False
    return run

四道闸不是「四选一」,而是四个并联的断路器。

步数闸防「步子多」。建议按段设定而不是全局设定:检索段八步、核验段三步,比一个全局的 max_steps=20 有用得多。

预算闸防「单步贵」。关键在于 reserve 是预扣而不是事后累加——事后累加意味着最后一次超支的调用已经发生了,钱已经花出去了。

工具闸防「做了不该做的事」。自主段内只允许只读工具,有副作用的工具一律不在白名单里,它们只能出现在固定编排段。这一道闸的触发应当按安全事件处理,而不是记一条统计。

进展闸防「一直在做但没有进展」。它最常被省略,也是「沉默转圈」的唯一防线——一个八步循环完全可以在步数与预算都合规的前提下,反复检索同一个关键词八次。这种情况不会报错,只会让用户等二十秒之后拿到一个和第一步就能给出的同样的答案。

另外注意 enough 这个参数:「证据够不够」这个终止决策由代码给出,不交给模型。它落在「代价大」那一侧——判早了会交付一份没有依据的报告,判晚了会一直烧预算。把它写成一个可测试的函数,是这份实现里最容易被跳过、也最值得保留的一个细节。

(五)降档:第二条路

降档不是错误处理的一种,而是设计时就要画出来的第二条路。

任务链定义了三条路径:正常路径、一次降档(自主段被闸门中断,用已有证据生成并标注「证据不完整」)、二次降档(路由失败,跳过自主段,转人工并保留上下文)。

try:
    res.task_type = orch.constrained(
        "route", f"用户说:{utterance}", TASK_TYPES, granted=granted, retries=1)
except DecisionError as exc:
    res.degraded, res.complete = "level2", False
    res.note = f"无法确定任务类型({exc.reason}),已转人工"
    orch.fixed("degrade", lambda: "handoff_human", "路由失败降档")
    return res, orch

降档有三条纪律。

第一,降档后的结果必须带标注。一份「证据不完整」的报告如果不标注,比没有报告更危险——它会被下游当成完整结论使用。

第二,降档路径本身必须是确定的。不能用模型来决定「现在要不要降档」,否则降档逻辑会继承它试图解决的那个问题。

第三,降档次数要进指标。降档率突然上升,通常是上游依赖或模型版本出了问题,而不是用户输入变了。

与之相对,最常见的错误做法是把降档写成 except 里的一行日志,然后继续往下跑。后果是:自主段失败之后,后续的固定编排段拿到一份空证据集,照样生成报告、照样写库、照样发通知。一次失败被安静地转换成了一次错误交付——这正是「不报错的事故」最典型的来源。

在示例实现里,这一点由副作用准入规则保证:

def side_effect_rule() -> str:
    if not res.complete:
        return "skip_incomplete"
    if res.task_type in {"refund_apply"}:
        return "require_approval"
    return "write_ticket"

不完整的结果,一律不触发副作用。

(六)运行输出

正常路径:

注入故障      : 无
任务类型      : order_query
证据条数      : 6
结果完整      : True   降档:无
副作用        : write_ticket
路径指纹      : b538d5e30527c700
闸门触发      : 无
Token 使用    : 3200/20000
--- 决策轨迹 ---
  ✓ intake       [固定编排] -> accepted            非空校验
  ✓ route        [受约束选择] -> order_query       命中关键字「订单」
  ✓ clarify      [受约束选择] -> enough            未命中脚本,退回第一个候选
  ✓ collect      [有界自主] -> kb_search           第 1 步,新增证据 2 条
  ✓ collect      [有界自主] -> db_readonly_query   第 2 步,新增证据 2 条
  ✓ collect      [有界自主] -> kb_search           第 3 步,新增证据 2 条
  ✓ collect      [有界自主] -> stop                自主段结束:done,共 3 步
  ✓ side_effect  [固定编排] -> write_ticket        副作用准入规则

注入「自主段试图调用写工具」这一类故障:

注入故障      : sideeffect
任务类型      : order_query
证据条数      : 2
结果完整      : False   降档:level1
副作用        : skip_incomplete
备注          : 证据收集提前结束(tool_denied)
闸门触发      : {'tool_denied': 1}

工具闸拦住了调用,自主段提前结束,结果被标记为不完整,副作用被跳过。整条链没有抛异常,也没有产生任何外部影响——这是降档设计生效的样子。

注入「预算提前耗尽」:

注入故障      : budget
证据条数      : 4
结果完整      : False   降档:level1
副作用        : skip_incomplete
闸门触发      : {'budget': 1}
Token 使用    : 2400/3000

注意最后一行:Token 使用停在 2400,没有超出 3000 的上限。这是预扣而非事后累加带来的直接结果。

八、从 Demo 版到生产版

(一)十项差异

环节Demo 里的常见写法生产版必须补上的
决策点定义散落在 if 与提示词里集中注册,带档位、候选来源与超时
候选集合硬编码在提示词字符串中按租户与权限动态生成,可回归
模型输出直接当作指令执行先解析为提议,经校验器裁决后执行
无效输出重试同一个提示,无上限有限重试,失败后按预设路径降档
自主段边界只有一个 max_steps步数、预算、工具白名单、进展四道闸
工具调用自主段可调用全部工具自主段仅只读工具,副作用工具隔离
失败处理捕获异常后继续执行显式降档,结果带不完整标注
轨迹记录打印到标准输出结构化落盘:候选、裁决、依据、耗时
测试跑通一个 happy path分档测试:枚举、抽样、失败注入
上线开关无按决策点的档位开关,可单点降档

(二)这张表的正确用法

不要把它当成「上线前要补的十件事」,而要当成「设计时就该有的十个位置」。

十项里有七项如果留到上线前再补,都需要改动编排层的接口。例如把「模型输出直接执行」改成「先裁决后执行」,会牵动每一个已经写好的节点;把「无上限重试」改成「有限重试 + 降档」,会牵动每一处调用点的错误处理。

经验做法是:在第一版就把「决策点注册 + 裁决 + 轨迹」这三件事搭好,其余七项可以按需逐步加严。这三件事加起来大约两百行代码,但它们决定了后面七项能不能以「加配置」而不是「改架构」的方式补上。

(三)关于上线开关

最后一行的「按决策点的档位开关」值得单独说一句。

它的价值不在于日常使用,而在于故障时刻:当某个受约束决策点的无效输出率突然飙升(通常意味着模型版本发生了变化),你需要在几分钟内把它降到固定编排档,用一条保守规则顶住,而不是回滚整个服务。

实现上,它只需要把 Level 从常量改成一个可以从配置读取的值,并为每个决策点准备一条「保守规则」作为降档目标。

九、失败模式与工程权衡

(一)七类失败模式

路径漂移:同样输入两次走出不同路径。成因是路由决策落在自主档。对策是降到受约束选择档。

分支爆炸:场景组合导致节点数失控。成因是把理解类决策压到固定档。对策是把理解部分升到第二档,让代码只处理「选出来之后怎么办」。

沉默转圈:一直在跑但证据不增加。成因是缺少进展闸。对策是补进展判定与停滞计数。

预算穿透:单次任务成本超预期数倍。成因是预算事后累加而非预扣。对策是调用前预扣与硬拒绝。

越权执行:自主段直接触发了副作用。成因是工具白名单缺失。对策是把副作用工具移出自主段。

静默降级:失败被吞掉,结果照常交付。成因是异常捕获后继续执行。对策是显式降档并标注结果。

兜底失灵:用户换个说法就进不了任何分支。成因是兜底分支没有被当成正式路径设计。对策是给兜底分支一条真正的处置路径——澄清、转人工或降级检索,而不是一句道歉。

(二)一个共同点

七类里有六类的根因都不在模型侧,而在「档位选错」或「闸门缺失」。

这意味着它们可以在代码评审阶段被发现,不需要等到线上。按七类逐条问五个问题,通常能在合并之前挡掉大部分后来会变成线上事故的写法:

1、这段代码里有没有「路由由模型直接决定」的位置?

2、有没有「没有进展判定的循环」?

3、有没有「自主段里出现写操作」?

4、有没有「except 之后继续往下走」?

5、有没有「兜底分支只是一句抱歉」?

(三)三个常见反例

1、「加一个校验的 Agent 就行了」

把模型的输出交给另一个模型去检查,是一个看起来很自然但经不起推敲的做法。

它的问题在于:第二个模型并不比第一个更有确定性。如果第一个模型可能选错路由,第二个模型也可能判错「这个路由对不对」。两次调用的错误不是独立的——它们面对的是同一段被误解的上下文。

正确的做法是用规则而不是用模型去做校验:候选是否在集合里、参数是否符合 Schema、租户是否有权限。这三件事都能写成确定的代码。

模型评审并非没有价值,但它属于「代价小、难检出」那一格的抽样复核手段,不能用来替代硬校验。

2、「用更强的模型就不会走错路了」

更强的模型会更少地走错路,但不会更少地走出不同的路。路径漂移的根因是路由决策没有被约束,而不是判断力不够。

换模型能把漂移率从 12% 降到 6%(示例值),但不能把它降到 0——而对于一个需要给客服解释「系统为什么这么做」的场景,6% 和 12% 的区别没有想象中那么大。

3、「先上线,监控发现问题再改」

这个说法在多数工程场景下成立,在这里部分不成立。

原因是本节的七类失败模式里,有三类不会触发任何告警:沉默转圈(没有错误,只是慢)、静默降级(没有错误,只是结果不完整)、兜底失灵(没有错误,只是用户走了)。

它们需要的不是监控,而是事前设计的指标——进展闸触发次数、降档率、兜底命中率。这三个指标如果没有在设计阶段被埋进去,上线后是补不出来的,因为没有对应的事件被记录过。

(四)核心权衡:可预测性与覆盖率

必须承认,本文的方法有明确代价。

覆盖率下降:受约束选择只能处理候选集合内的意图。候选集合之外的请求会走兜底,而一个纯自主的系统可能会「凑合着处理」。

候选集合的维护成本:每增加一类业务,要注册一个候选、写一条 when 说明、补一组回归样本。这部分工作量是持续的。

对业务理解的要求更高:要划分档位,就必须先说清每个决策点的代价,这需要业务方参与。一个纯自主的实现可以完全由工程团队闭门完成。

什么时候不该用本文的方法?两种情况。

一是探索期:业务边界还没画出来,路径分布还没统计过。这时候固化的每一条规则都可能在两周后被推翻。

二是真正的开放域任务:例如「研究一个我们从未接触过的领域并给出报告」,它的价值恰恰来自于路径不可预设。这类任务应该放在受控沙箱里,用资源限额而不是路径约束来控制风险。

十、三层验证

三层各自回答不同的问题,缺一层就会出现「测了但没测到」。

(一)第一层:路径一致性测试

问题:同一个输入,多次运行是否走同一条路径?

方法:固定输入跑 N 次(建议 N ≥ 20),统计路径指纹的分布。

1、路径指纹怎么算

把一次运行中被求值的决策点按顺序取出,拼成「决策点标识:裁决结果」的序列,做哈希:

def fingerprint(self) -> str:
    """路径指纹:只记录决策点与裁决结果,自主段内的步数不计入。"""
    seq = "|".join(f"{v.point}:{v.chosen}" for v in self.trace if v.accepted)
    return hashlib.sha256(seq.encode("utf-8")).hexdigest()[:16]

关键在于自主段内的步数不计入指纹,只记录「进入自主段」与「以何种方式退出」。否则指纹会因为正常的步数波动而全都不同,这个指标就失去了意义。

2、通过标准

固定编排段与受约束段的路径指纹必须完全一致;自主段允许分布,但必须落在事先声明的退出原因集合内(done / max_steps / budget / no_progress / tool_denied)。

def test_fingerprint_stable_across_runs(self) -> None:
    prints = {run("我的订单一直显示处理中")[1].fingerprint() for _ in range(20)}
    self.assertEqual(len(prints), 1, f"路径不稳定,出现 {len(prints)} 种指纹:{prints}")

失败含义很明确:有一个本该确定的决策点没有被约束住。

(二)第二层:决策点级评测

问题:每一个决策点单独看,判对了吗?

方法:为每个受约束决策点构造样本集,标注期望候选;对自主段标注期望的「是否应当继续」。

def test_candidates_filtered_by_permission(self) -> None:
    """没有 refund:write 权限时,退款候选不应出现在候选集合里。"""
    orch = self._orch(script={"退款": "refund_apply"})
    key = orch.constrained("route", "我要退款", TASK_TYPES, granted=GRANTED, retries=0)
    self.assertNotEqual(key, "refund_apply")
    self.assertNotIn("refund_apply", orch.trace[-1].candidates)

1、通过标准的设计

路由类决策点的准确率要设门槛,但更重要的是单独统计「错到高代价候选」的比例。

把「订单查询」误判成「知识库问答」和误判成「退款申请」,在总体准确率上是同一个数字,在业务上完全不是一回事。后者应当设一个独立的、更严的硬门槛。

2、闸门要能单独测

四道闸各自应该有独立测试,不依赖完整任务链:

def test_budget_gate_reserves_before_call(self) -> None:
    orch = self._orch(tokens=1_600)          # 只够两步
    r = orch.bounded("c", lambda i, ev: ({f"e{i}"}, "kb_search"), max_steps=8)
    self.assertEqual(r.stop_reason, "budget")
    self.assertLessEqual(orch.budget.used_tokens, 1_600, "预算必须预扣,不能事后超支")

这类测试的价值在于它们会在重构时失败。当有人把 reserve 从调用前挪到调用后(这是一个看起来无害的改动),这个断言会立刻红掉。

(三)第三层:端到端与失败注入

问题:链路整体在异常下的行为符合预期吗?

方法:注入六类故障——模型返回非法候选、模型返回空输出、模型调用抛异常、预算提前耗尽、检索无新增证据、自主段试图调用写工具。

通过标准:每一类都必须走到预设的降档路径,结果带不完整标注,且不产生任何副作用。

def test_all_faults_produce_no_side_effect(self) -> None:
    for fault in ("illegal", "empty", "boom", "budget", "stall", "sideeffect"):
        with self.subTest(fault=fault):
            res, _ = run("我的订单一直显示处理中", fault=fault)
            self.assertFalse(res.complete, f"{fault} 下结果不应被标记为完整")
            self.assertIn(res.side_effect, {"none", "skip_incomplete"},
                          f"{fault} 下不应产生任何副作用")

这一个测试覆盖了六种故障 × 两个断言。它的价值在于它直接对应了本文最重要的一条设计约束:不确定性可以影响结果质量,但不能影响副作用。

完整运行结果:

Ran 14 tests in 0.001s

OK

(四)三层之外还要补的一件事

回归基线要连同档位配置一起版本化。

一个隐蔽的问题是:有人把某个决策点从受约束档临时改成自主档,以解决一个漏判。改动只有一行配置,评测集全部通过,但系统的可预测性已经发生了实质变化——而没有任何一个测试会失败,因为测试用的是同一份配置。

对策是:档位变更需要单独评审,并在发布说明里显式列出。这一条看起来像流程约束,实际上是技术约束——它可以用一个简单的 CI 检查来强制:配置文件里的 level 字段发生变化时,要求 PR 描述中包含一段说明。

十一、安全、性能与成本影响

(一)安全

分档带来的最直接的安全收益,是把「模型输出可以触发什么」这个问题变成了一个可以静态回答的问题。

在一个全自主实现里,回答「这个 Agent 会不会调用退款接口」需要读提示词、读工具注册表、读循环逻辑,而且得到的答案是概率性的。在分档实现里,只需要看一件事:退款工具出现在哪些段的白名单里。如果它只出现在固定编排段,答案就是确定的。

这一点在应对间接提示注入时尤其重要。被污染的检索结果可能让模型产生「我应该调用退款接口」的意图,但工具闸会在执行前把它拦下来,并且——这是关键——把它记录成一次工具闸触发。

建议把自主段的工具闸触发按安全事件处理,而不是当成一条统计记录。正常情况下这个计数应当长期为零;一旦非零,要么是白名单配置漏了,要么是有人在试探边界。

需要说明清楚的是,分档并不消除注入风险,它只是把风险的作用面从「可以做任何事」收缩到「可以影响内容质量」。输入过滤、上下文隔离、输出校验这些防线仍然需要,本文的方法是其中的一层,不是全部。

(二)性能

分档对时延的影响有两个方向。

变好的部分:固定编排段不需要模型调用,受约束选择段只需要一次短调用(候选集合通常只有十几个 key,提示词很短)。相比之下,一个自主循环即使在最简单的任务上也要至少两到三次完整的模型调用。

变差的部分:受约束选择是一次额外的模型调用。如果原本的实现是「一次调用同时完成意图理解和内容生成」,拆开之后会多一次往返。

一个经验性的判断是:在任务链超过三步之后,分档的时延收益会超过它的开销,因为它避免了自主循环中大量的无效步骤。但这取决于具体的模型时延与任务形态,不应当当成通用结论。

按段统计时延是必须的。示例实现里每个 Verdict 都带 elapsed_ms,聚合之后就能回答「这条链的时间花在哪一段」。绝大多数情况下答案是自主段,而这也正是优化应当投入的地方——通常是把串行的检索改成并行,而不是换一个更快的模型。

(三)成本

成本的可预测性是分档最直接的收益。

单次任务的成本上界可以被精确计算:固定段为零,每个受约束段一次短调用,自主段不超过预算闸设定的上限。把这三项加起来,就是这条链的成本上界。

相比之下,一个没有预算闸的自主循环,其成本上界等于「模型愿意循环多少次」——这不是一个可以写进容量规划文档的数字。

三个具体的成本建议:

1、预扣而非累加。示例实现里 Budget.reserve 在调用前检查并占用额度,这保证了超支永远不会真的发生。

2、按段设预算,而不是按任务设。一个全局预算会被第一个自主段用光,后面的段拿不到资源。

3、把降档也算进成本模型。降档会导致一部分任务需要人工处理,这部分成本要计入,否则会得到「压低预算上限总是省钱」的错误结论——实际上预算压得太低,降档率上升,人工成本会把节省的 Token 费用吃掉。

(四)九个上线指标

前四个衡量确定性是否成立,后五个衡量代价是否可接受:

指标看什么
路径指纹熵分布是否稳定,突变即预警
受约束档无效输出率模型返回非法候选的比例
自主段进入率有多少任务真的需要自主
降档率(分一次 / 二次)突升通常指向上游异常
自主段平均步数与 P95均值正常但尾部长是典型信号
四道闸各自触发次数哪道闸在挡,挡得对不对
单任务成本 P50 / P95尾部成本决定预算是否够
端到端时延按段分解自主段占比是优化的主战场
兜底分支命中率上升说明候选集覆盖不足

阈值怎么定:不要一上来就设绝对阈值。正确顺序是先按现有流量采一到两周的分布,取 P95 作为观察线、P99 作为告警线,再根据业务容忍度收紧。

唯一应当一开始就设硬阈值的是工具闸:自主段出现一次副作用工具调用尝试,就应该触发告警,而不是等它累积到某个比例。

十二、生产检查清单

(一)设计阶段

  • [ ] 任务链画成方框并标注了档位
  • [ ] 每个决策点归入了路由 / 参数 / 内容 / 终止 / 异常五类之一
  • [ ] 高代价决策点全部落在固定编排或受约束选择档
  • [ ] 自主段不超过两处
  • [ ] 兜底路径被当成正式路径设计,而不是一句道歉

(二)实现阶段

  • [ ] 决策点集中注册,档位可通过配置调整
  • [ ] 候选集合按租户与权限动态生成,不硬编码在提示词里
  • [ ] 模型输出先解析为提议,经校验器裁决后才执行
  • [ ] 自主段四道闸(步数、预算、工具、进展)全部就位
  • [ ] 副作用工具不在任何自主段的白名单里

(三)验证阶段

  • [ ] 路径一致性测试跑满 N 次且指纹唯一
  • [ ] 每个受约束决策点有独立样本集,并单独统计「错到高代价候选」的比例
  • [ ] 六类故障注入全部走到预设降档路径,且无副作用
  • [ ] 档位配置随代码一起版本化,变更需单独评审
  • [ ] 评测基线覆盖兜底与降档分支,而不只是正常路径

(四)运行阶段

  • [ ] 九个指标全部有看板
  • [ ] 工具闸触发按安全事件处理
  • [ ] 降档率与兜底命中率设了告警
  • [ ] 决策轨迹五个字段完整落盘,且与任务状态同表
  • [ ] 每季度复核一次档位是否仍然合适

(五)清单之外的一句话

这二十项里没有任何一项是「换一个更强的模型」。

这不是说模型能力不重要,而是说:本文讨论的七类失败模式中,有六类在换模型之后依然会发生——因为它们的成因是控制权的归属出了问题,而不是判断力不够。

一个把路由交给模型的系统,换上更强的模型之后,会更聪明地走向不同的路径;一个没有进展闸的循环,换上更强的模型之后,会更有耐心地转更多圈。

确定性来自结构,不来自能力。 这是本文全部内容的落脚点。

十三、结论与延伸

(一)本文结论

第一,确定性不是系统级开关,而是决策点级别的属性。把整个系统标记为「确定的」或「自主的」,无论拨到哪一边都会失败。

第二,一次任务里的决策至少有五类:路由、参数、内容、终止、异常。团队通常为了「内容」引入模型,却顺手把另外四类也交了出去。

第三,归属判据是「错误代价 × 可检出性」。代价大且难检出的必须由代码判定;代价小且易检出的交给模型;中间两格用「模型提议 + 代码裁决」。

第四,控制强度有四档而不是两档。四档的意义在于让不确定性可以被关进一两个小盒子,而不是弥漫在整条链上。

第五,有界自主需要四道闸:步数、预算、工具白名单、进展判定。前两道最常见,第四道最常被省略,而它是「沉默转圈」的唯一防线。

第六,降档是设计时就要画出来的第二条路,不是 except 里的一行日志。降档结果必须带标注,且不完整的结果一律不触发副作用。

第七,验证要分三层:路径一致性、决策点级评测、端到端失败注入。缺任何一层都会出现「测了但没测到」。

第八,七类失败模式中有六类的根因不在模型侧,因此可以在代码评审阶段被发现——这比上线后加监控便宜得多。

(二)与专栏其他文章的衔接

本文是本专栏《Agent 工作流实战》专栏导读地图与阅读路径说明-CSDN博客第二部分「工作流与任务编排」的开篇,给出的是这一部分的总判据。后续文章会把每一段展开:

向前看,本文使用的分类方法建立在第一部分的判定之上:需求形态怎么判定,见《先别急着做 Agent:Chatbot、Workflow 与 Agent 的边界》;决策点在系统里的位置与层间契约,见《用一个业务场景画出生产级 Agent 的全景架构》;本文实现中省略的模型访问细节,见《不依赖框架实现最小 Agent:模型、工具、观察与终止》。

参考资料

编排模式与自主边界

确定性、状态机与持久化执行

契约、校验与结构化输出

可靠性与失败注入

安全与风险

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/xiaofeng10330111/article/details/168327244

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--