「为什么不让模型自己决定步骤」
「回声」的客服链路 B 已经稳定:判意图、检索、答复三步,成功率与成本都在预算内。产品提出一个新想法:既然模型已经能判断意图,为什么不让它自己决定要不要检索、检索几次、要不要转人工?这样长尾问题也许能处理得更好。
团队做了一个原型。模型可以调用检索、摘要、回复、转人工四个工具,最多不限步数。前十条测试请求看起来很聪明:有的跳过了不必要的检索,有的在答复前多查了一次订单状态。上线到内部试用后,问题很快出现。
有的请求走了十一步,成本是固定编排的四倍;有的请求在第第三步就卡住,因为模型反复调用同一个工具;最麻烦的是复盘:同一个用户问题,两次运行的路径不同,无法解释为什么第一次转人工、第二次没有。团队可以接受「模型做了不同选择」,但不能接受「没有人能说清它为什么这么选」。
这不是 Agent 能力不足,而是边界没有定义。本节讨论固定编排与自主 Agent 的边界判据,并给出可运行的评分与硬否决。文中的评分来自 ai-lab 的示例样本运行结果,不是生产实测。本节不写 Agent 框架教程,只回答「这个任务该不该交给 Agent」。
三种结构,不是两种
讨论「要不要上 Agent」之前,先把结构分成三种。很多争论来自把中间形态漏掉了。
| 结构 | 谁决定路径 | 适用条件 | 典型失败 |
|---|---|---|---|
| 固定编排 | 代码 | 路径可枚举,步骤与顺序已知 | 长尾任务没有分支可走 |
| 有界 Agent | 代码给骨架,模型在骨架内选工具 | 路径部分未知,但步骤与预算可封顶 | 骨架过窄,Agent 没有发挥空间 |
| 自主 Agent | 模型 | 路径不可枚举,失败便宜且可逆 | 成本与行为不可预测 |
有界 Agent 是大多数「确实需要一点自主性」的场景的落点:外层仍是固定编排,负责权限、预算、审计与终止条件;内层允许模型在有限工具集与有限步数内选择。它不是「小一号的自主 Agent」,而是「带护栏的固定编排扩展」。
本卷的主张很明确:大多数产品请求应保持固定编排。不是因为 Agent 不好,而是因为大多数产品请求的路径本来就可枚举,失败代价也不低。把可枚举的路径交给模型重新发现,只会增加方差,不会增加能力。
四项硬判据
边界判定先看四项硬判据,任何一项不满足,就不进入自主 Agent。
| 判据 | 问什么 | 不满足时的结论 |
|---|---|---|
| 路径是否可枚举 | 能否在编译期列出全部步骤与分支 | 可枚举就留在固定编排 |
| 步骤上限 | 最坏情况下最多几步,能否封顶 | 超过 24 步先拆任务,不交给 Agent |
| 失败代价 | 一次失败的业务补救成本是多少 | 达到 1,000,000 微元直接否决 |
| 审计要求 | 是否要求逐步复现与责任归属 | 要求审计且动作不可逆时直接否决 |
四项里最容易被跳过的是「路径是否可枚举」。团队常把「我们还没想全」当成「路径不可枚举」。这两者不同:前者是设计没做完,后者是任务本身开放。判断方法是问「如果把所有合法输入列出来,步骤集合是否收敛」。客服 FAQ 会收敛;开放研究不会。
失败代价要按业务补救成本算,不是按模型调用价算。一次订单创建失败,模型只花了几千微元,但重下单、补偿用户、客服介入的成本可能是几元甚至几十元。示例把 1,000,000 微元(1 元)设为硬否决线,是一个可调整的起点,不是通用常数。
审计要求也不是「有没有日志」。固定编排天然有节点级记录;自主 Agent 的日志是模型自己选的路径,同一输入可能产生不同轨迹。如果业务要求「同一请求必须能复现同一条处理路径」,自主 Agent 就不合适;如果只要求「事后能看出调用了哪些工具」,有界 Agent 可以做到。
评分与硬否决
四项硬判据之外,还有三个软因素参与评分:动作是否可逆、任务分布是否漂移、可用工具数量。它们不单独否决,但会改变建议落在哪个区间。
评分采用 0 到 1 的加权和,0 表示更像固定编排,1 表示更像自主 Agent。权重如下:
| 维度 | 权重 | 取 1 的条件 |
|---|---|---|
| 路径 | 0.30 | 路径不可枚举 |
| 步骤 | 0.15 | 典型步骤达到 12 步以上 |
| 失败代价 | 0.20 | 失败代价接近 100 微元 |
| 审计 | 0.15 | 不要求逐步审计 |
| 可逆性 | 0.10 | 动作可撤销或可补偿 |
| 任务漂移 | 0.05 | 任务高度长尾 |
| 工具数量 | 0.05 | 工具达到 10 个以上 |
总分达到 0.70 才建议自主 Agent,0.45 到 0.70 建议有界 Agent,低于 0.45 留在固定编排。硬否决优先于分数:只要命中任意一项,一律回到固定编排。
实现
下面的模块把硬否决、加权评分与护栏放在一起。它不执行任务,只回答「这类请求应该用哪种结构」。
// file: src/orch/agent-boundary.ts
// tier: L2
// verify: node --test test/orch/evolution.test.ts
import { AiError } from "../core/errors.ts";
export type BoundaryRecommendation = "fixed" | "bounded-agent" | "autonomous-agent";
export type BoundaryComponentId =
| "path"
| "steps"
| "failure-cost"
| "audit"
| "reversibility"
| "variability"
| "tools";
export interface BoundaryInput {
/** 任务路径能否在编译期枚举;可枚举时固定编排已经能表达全部路径。 */
pathEnumerable: boolean;
/** 典型任务需要的步骤数;未知时不要用 0,而应显式给出上限。 */
typicalSteps: number;
/** 单次失败的业务代价,整数微元。 */
failureCostMicros: number;
/** 是否要求每一步可审计、可复现。 */
auditRequired: boolean;
/** 动作是否可逆:可撤销、可补偿、可重放。 */
reversible: boolean;
/** 任务分布漂移:0 稳定,1 高度长尾。 */
taskVariability: number;
/** 可调用工具数;工具越多,路径越难枚举。 */
toolCount: number;
}
export interface BoundaryComponent {
id: BoundaryComponentId;
weight: number;
/** 0 表示更像固定编排,1 表示更像自主 Agent。 */
value: number;
note: string;
}
export interface BoundaryDecision {
score: number;
recommendation: BoundaryRecommendation;
components: BoundaryComponent[];
vetoes: string[];
guardrails: {
maxSteps: number;
maxCostMicros: number;
requiresHumanCheckpoint: boolean;
auditTrail: boolean;
};
}
/** 权重之和为 1;硬判据不参与加权,而是直接否决。 */
export const BOUNDARY_WEIGHTS: Record<BoundaryComponentId, number> = {
path: 0.3,
steps: 0.15,
"failure-cost": 0.2,
audit: 0.15,
reversibility: 0.1,
variability: 0.05,
tools: 0.05,
};
export const STEP_CAP = 24;
export const HIGH_FAILURE_COST_MICROS = 1_000_000;
export const CHECKPOINT_FAILURE_COST_MICROS = 100_000;
export const AGENT_THRESHOLD = 0.7;
export const BOUNDED_THRESHOLD = 0.45;
function fail(message: string): never {
throw new AiError("invalid-request", `agent-boundary: ${message}`);
}
function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
function finite(value: number, field: string): number {
if (!Number.isFinite(value) || value < 0) fail(`${field} 必须是 >= 0 的有限数`);
return value;
}
function validate(input: BoundaryInput): void {
if (!Number.isInteger(input.typicalSteps) || input.typicalSteps < 1) fail("typicalSteps 必须是正整数");
finite(input.failureCostMicros, "failureCostMicros");
if (!Number.isFinite(input.taskVariability) || input.taskVariability < 0 || input.taskVariability > 1) {
fail("taskVariability 必须落在 [0, 1]");
}
if (!Number.isInteger(input.toolCount) || input.toolCount < 0) fail("toolCount 必须是 >= 0 的整数");
}
/** 失败代价越低,越适合让 Agent 试错;按对数插值,避免被极端值拉偏。 */
function costAffinity(failureCostMicros: number): number {
const low = Math.log10(100);
const high = Math.log10(HIGH_FAILURE_COST_MICROS);
const value = Math.log10(Math.max(failureCostMicros, 1));
return clamp01((high - value) / (high - low));
}
function stepAffinity(steps: number): number {
return clamp01((steps - 4) / 8);
}
function toolAffinity(toolCount: number): number {
return clamp01((toolCount - 2) / 8);
}
/**
* 固定编排与自主 Agent 的边界判定。
* 先收集硬否决,再加权评分;有任意否决时一律回到固定编排。
*/
export function decideBoundary(input: BoundaryInput): BoundaryDecision {
validate(input);
const components: BoundaryComponent[] = [
{ id: "path", weight: BOUNDARY_WEIGHTS.path, value: input.pathEnumerable ? 0 : 1, note: input.pathEnumerable ? "路径可枚举" : "路径不可枚举" },
{ id: "steps", weight: BOUNDARY_WEIGHTS.steps, value: stepAffinity(input.typicalSteps), note: `${input.typicalSteps} 步` },
{ id: "failure-cost", weight: BOUNDARY_WEIGHTS["failure-cost"], value: costAffinity(input.failureCostMicros), note: `${input.failureCostMicros} 微元` },
{ id: "audit", weight: BOUNDARY_WEIGHTS.audit, value: input.auditRequired ? 0 : 1, note: input.auditRequired ? "要求逐步审计" : "不要求逐步审计" },
{ id: "reversibility", weight: BOUNDARY_WEIGHTS.reversibility, value: input.reversible ? 1 : 0, note: input.reversible ? "可逆" : "不可逆" },
{ id: "variability", weight: BOUNDARY_WEIGHTS.variability, value: input.taskVariability, note: `漂移 ${input.taskVariability}` },
{ id: "tools", weight: BOUNDARY_WEIGHTS.tools, value: toolAffinity(input.toolCount), note: `${input.toolCount} 个工具` },
];
const score = Number(components.reduce((sum, component) => sum + component.weight * component.value, 0).toFixed(4));
const vetoes: string[] = [];
if (input.pathEnumerable) vetoes.push("path-enumerable");
if (input.auditRequired && !input.reversible) vetoes.push("audit-irreversible");
if (input.failureCostMicros >= HIGH_FAILURE_COST_MICROS) vetoes.push("failure-cost");
if (input.typicalSteps > STEP_CAP) vetoes.push("step-cap");
let recommendation: BoundaryRecommendation =
score >= AGENT_THRESHOLD ? "autonomous-agent" : score >= BOUNDED_THRESHOLD ? "bounded-agent" : "fixed";
if (vetoes.length > 0) {
recommendation = "fixed";
} else if (!input.reversible && recommendation === "autonomous-agent") {
recommendation = "bounded-agent";
}
const maxSteps = recommendation === "fixed"
? input.typicalSteps
: recommendation === "bounded-agent"
? Math.min(12, Math.max(6, input.typicalSteps + 2))
: STEP_CAP;
return {
score,
recommendation,
components,
vetoes,
guardrails: {
maxSteps,
maxCostMicros: Math.max(1_000, Math.floor(input.failureCostMicros / 2)),
requiresHumanCheckpoint: input.auditRequired || !input.reversible || input.failureCostMicros >= CHECKPOINT_FAILURE_COST_MICROS,
auditTrail: input.auditRequired || recommendation !== "fixed",
},
};
}
/** 一组任务的建议分布;用来确认「大多数请求仍然是固定编排」。 */
export function summarizeBoundary(decisions: readonly BoundaryDecision[]): Record<BoundaryRecommendation, number> {
const summary: Record<BoundaryRecommendation, number> = {
fixed: 0,
"bounded-agent": 0,
"autonomous-agent": 0,
};
for (const decision of decisions) summary[decision.recommendation] += 1;
return summary;
}
decideBoundary 先收集否决,再算分数。这个顺序很重要:如果先看分数,一个 0.9 分的任务会因为「看起来很需要 Agent」而绕过失败代价或审计约束。否决项是硬边界,不参与加权。
护栏也不是装饰。maxSteps 给出结构上限,maxCostMicros 给出探索预算,requiresHumanCheckpoint 在不可逆或高失败代价时强制人工确认。这些值会随建议变化,但不会缺失。
示例样本上的运行结果
用六类示例任务跑判定,结果如下。分数是加权和,否决项决定是否直接回到固定编排。
| 任务 | 分数 | 建议 | 否决项 | 步数上限 |
|---|---|---|---|---|
| FAQ 答复 | 0.2304 | fixed | path-enumerable | 3 |
| 内容发布 | 0.0376 | fixed | path-enumerable, audit-irreversible | 4 |
| 站内下单 | 0.0300 | fixed | path-enumerable, audit-irreversible, failure-cost | 3 |
| 开放研究 | 0.9661 | autonomous-agent | 无 | 24 |
| 客服分诊 | 0.6912 | bounded-agent | 无 | 7 |
| 退款处理 | 0.4437 | fixed | audit-irreversible | 8 |
六类任务里四类留在固定编排,一类有界 Agent,一类自主 Agent。这个分布不是巧合,而是产品请求的常态:大多数任务的路径本来就能枚举,失败代价也不低。
开放研究是唯一进入自主区间的样本:路径不可枚举、15 步左右、失败代价 300 微元、可逆、不要求逐步审计、10 个工具。它的分数 0.9661,护栏是 24 步、1,000 微元探索预算、不需要人工检查点。
客服分诊是有界 Agent 的典型:路径不完全可枚举,但步骤、工具与预算都能封顶。分数 0.6912,护栏是 7 步、10,000 微元探索预算、保留审计轨迹。它没有进入自主区间,因为失败代价 20,000 微元仍然不低,任务漂移也只有 0.5。
退款处理的分数 0.4437 本来接近有界区间,但「要求审计且动作不可逆」直接否决。这个样本说明分数与否决的关系:分数可以讨论,否决不能谈判。
有界 Agent 怎么落在产品里
有界 Agent 不是把固定编排删掉,而是在固定骨架里开一个受控的口子。外层仍然负责身份、权限、预算、审计与终止;内层允许模型在给定工具集里选择下一步,但每一步都要回到外层登记。
一个可落地的形态是「固定前处理 + 有界循环 + 固定后处理」。前处理解析意图、加载上下文、确定预算;循环最多 N 步,每步只能调用白名单工具,每步后检查预算与终止条件;后处理做结构化校验、敏感内容检查与交付。模型只影响循环内部的顺序,不影响外层契约。
终止条件至少要四个:步数上限、成本上限、连续重复工具调用、连续无进展。前两个是预算,后两个是行为异常。只设步数上限不够,模型可以在两步内反复调用同一个昂贵工具。
判定的五个问题
把边界判定压缩成评审时的五个问题:
- 如果把所有合法输入列出来,步骤集合是否收敛?收敛就留在固定编排。
- 最坏情况下最多几步?超过 24 步,先拆任务,不要用 Agent 兜底。
- 一次失败的业务补救成本是多少?达到 1,000,000 微元,直接否决。
- 是否要求同一请求复现同一条路径?要求就选固定编排或有界 Agent。
- 动作是否可逆?不可逆且要求审计,直接否决。
五个问题里只要有一个指向固定编排,就不要用分数去推翻它。分数是用来区分「有界」与「自主」的,不是用来突破硬边界的。
与全书的衔接
本节是卷三的最后一节正式讨论。前面十一章回答了结构、交接、执行图、上下文、失败、人工、评测、预算、安全、观测与平台化;本节回答这些结构之上的一层选择:什么时候不增加结构,什么时候只增加一点。
下一章回到「回声」本身,给出一份可评审的编排基线,把全书的结构选择、数字口径与验收门槛收拢到一张表里。
小结
固定编排与自主 Agent 的边界由四项硬判据决定:路径是否可枚举、步骤是否有上限、失败代价是否可承受、审计是否可复现。硬否决优先于加权分数;分数只区分有界 Agent 与自主 Agent。
示例样本中,六类任务有四类留在固定编排,一类进入有界区间,一类进入自主区间。这个分布支持本卷的主张:大多数产品请求应保持固定编排。Agent 的价值在路径真正未知的地方,而不是在已知路径上重新发现步骤。
阅读导航:上一节:12.2 编排版本与灰度 · 下一节:13.1 回声的编排基线 。
常见错误与边界
- 把「还没想全」当成「路径不可枚举」。设计没做完与任务开放是两件事。
- 用模型调用价衡量失败代价。业务补救成本通常高出几个数量级。
- 先看分数再看否决。高分任务会绕过审计与不可逆约束。
- 只设步数上限。模型可以在少数步内反复调用昂贵工具。
- 把有界 Agent 当成小号自主 Agent。它外层仍是固定编排,护栏不能省。
- 让 Agent 决定权限与预算。外层契约不能交给模型。
- 没有终止条件里的「无进展」判断。循环会在原地打转,直到预算耗尽。
- 要求逐步复现却选自主 Agent。同一输入可能产生不同轨迹。
- 用一次演示的成功证明 Agent 可上线。方差与长尾才是边界问题的来源。
- 在可枚举路径上引入 Agent。增加的是方差,不是能力。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。