跳转到内容

Workflow 与 Agent

1. 具体工程问题:客服退款到底需不需要 Agent

Section titled “1. 具体工程问题:客服退款到底需不需要 Agent”

假设系统收到一句话:“昨天重复扣款了,帮我退款。”它可能需要:

  1. 找到用户和订单;
  2. 检查是否真的重复扣款;
  3. 判断支付渠道和退款规则;
  4. 生成退款计划;
  5. 在获得确认后调用支付接口;
  6. 验证退款单是否创建成功;
  7. 把结果通知用户。

表面看,这是一串步骤,似乎适合固定工作流(Workflow)工作流Workflow由代码预先决定步骤、分支和停止条件的执行路径。打开术语条目 →。但真实输入可能缺少订单号、一个用户可能有多笔订单、不同渠道规则不同,检索结果也可能冲突。路径中存在需要根据现场状态选择下一步的部分。

错误的结论是:“既然有不确定性,就让 Agent 自己处理全部流程。”退款具有外部副作用,错误动作难以撤回;如果模型可以直接调用支付接口,它可能选错订单、重复退款、把超时误判为失败后再次提交,或者在证据不足时仍宣称完成。

更可靠的拆分是:

  • 固定入口: 身份、订单范围、退款规则和请求状态由程序读取;
  • 模型可选区域: 在多个候选订单或证据中提出下一项调查动作;
  • 固定出口: 金额、订单、权限、确认和幂等身份由程序验证;
  • 高风险提交: 进入独立确认步骤,不能由模型自行批准;
  • 完成判定: 查询支付系统的退款单,而不是相信模型说“已完成”。

这不是“Workflow 或 Agent 二选一”。生产系统通常是 Workflow 包围一个受控 Agent 区域

CODE-DRIVEN

固定 Workflow

程序提前决定步骤、分支和停止条件。

  1. 读取输入
  2. 运行固定步骤
  3. 检查明确条件
  4. 返回或进入人工处理

MODEL-DIRECTED, CODE-GUARDED

受控 Agent

模型选择下一项动作,程序控制权限、预算和终止。

  1. 观察公开状态
  2. 选择一个候选动作
  3. 程序校验并执行工具
  4. 验证结果,更新状态或停止

2. 编排模式:不是只有“固定”或“自主”两档

Section titled “2. 编排模式:不是只有“固定”或“自主”两档”

从单次模型调用到受控 Agent,中间至少有六种常见模式。差别不在于用了多少个 Prompt,而在于下一步由谁选择、结果由谁合并、循环由谁停止

模式模型决策点程序必须拥有的控制适用问题主要失败路径
单次调用0–1输入、输出Schema和失败处理分类、抽取、改写输出不合法、事实错误
Prompt Chaining每个固定阶段一次顺序、阶段输入和停止先抽取再生成、先计划再格式化上游错误向后传播
Routing选择一个分支路由标签、默认分支和回退按意图选择工具或专家路由漂移、未知类别
Parallel Fan-out/Fan-in多个独立子任务并发上限、合并规则和部分失败策略多来源检索、独立检查慢节点拖尾、结果冲突
Evaluator–Optimizer生成与评价交替通过条件、轮数和改进阈值文案修订、代码修复评价器偏差、无限互改
Orchestrator–Workers动态拆分子任务Worker边界、依赖图和汇总验证边界可分的复杂调查重复工作、依赖遗漏
受控 Agent Loop每轮选择下一动作动作集合、预算、验证、状态和终止路径无法预先枚举循环、越权、成本失控

程序提前知道阶段:例如“抽取字段 → 检索证据 → 生成带引用答案”。模型只在每个阶段完成受限转换。若第二阶段失败,程序知道停在哪里,也能单独重放。

不要为了“更Agent”而让模型重新决定是否需要抽取或检索;如果步骤总是相同,动态选择只增加故障面。

Router 输出有限标签,例如 billingtechnicalaccountunknown。程序把标签映射到固定分支。unknown 是必要状态,不应该把所有低置信输入硬塞进最相近分支。

Router 的正确率不能只看整体 Accuracy。高风险分支还要单独检查误路由率:把普通咨询路由到只读FAQ通常可恢复,把退款请求路由到可写支付工具则需要更严格的后续门槛。

并行适合互相独立的工作:同时读取三份公开材料,或让三个确定性检查器分别验证格式、引用和安全边界。Fan-in 必须定义:

  • 是否要求所有分支成功;
  • 部分失败能否返回降级结果;
  • 冲突结果怎样仲裁;
  • 慢分支何时取消;
  • 汇总是否保留每个分支的来源身份。

“开十个Agent一起想”不是并行策略。没有独立边界和合并规则,只会把一个不确定任务复制十次。

生成器产出候选,评价器给出结构化缺口,生成器据此修订。循环的通过条件必须由程序读取,例如 allRequiredCitationsPresent === true,而不是评价器写一句“现在很好”。还要设置最大轮数和最小改进量;连续两轮得分不变应停止,而不是继续消耗预算。

Orchestrator 适合在运行时拆出边界清楚的子任务,例如分别比较三个API的认证、限流和错误语义。Worker不应再无限创建Worker;依赖和汇总仍由程序检查。

多Agent是一种拓扑,不是能力证明。若一个模型加三个工具已经能完成任务,增加角色不会自动提高正确率。

先不要问“哪个框架最流行”,而要问下面四个问题。

  • 能枚举全部步骤和分支:优先固定Workflow;
  • 能枚举大部分路径,但某一步需要语义选择:在固定流程中加入Router或一个模型决策点;
  • 下一步依赖不断变化的外部观察:才考虑受控Agent Loop。

如果完成条件可以由Schema、数据库查询、测试或外部状态验证,模型选择动作后仍能由程序收口。若结果主要依赖主观判断,要增加人工评价或多层评测,而不是让同一个模型既生成又宣布自己成功。

副作用默认策略
无副作用读取可在预算内自动执行
可重放计算可自动重试,但保留输入和版本
幂等写入使用稳定幂等键并验证后置条件
可补偿写入记录原状态和补偿动作,失败时对账
不可逆或高影响操作固定流程、明确确认、最小权限和人工接管

工具返回200不代表业务目标成立;Timeout也不代表外部写入没有发生。越难观察真实后置条件,越不能把控制权交给开放循环。

ARCHITECTURE COMPASS

任务应该落在哪种架构里?

沿两条轴移动任务:路径越未知,越需要模型决策;副作用越高,越需要固定流程包围。

固定 Workflow
任务不确定性与副作用风险架构选择图横轴是路径不确定性,纵轴是副作用风险。当前点位于固定 Workflow区域。固定 WORKFLOWWORKFLOW 包围 AGENT受控 AGENT路径明确路径未知路径不确定性 →副作用风险 →
当前建议固定 Workflow

路径已经足够清楚,让代码决定步骤、分支和停止条件。

高风险步骤仍需权限检查、确认、幂等和审计。

智能体(Agent)智能体Agent在程序边界内,由模型根据当前状态选择下一步动作的系统。打开术语条目 →的定义不是“模型可以做任何事”,而是“模型在程序边界内选择下一步”。模型输出是候选,不是已经授权的命令。

CONTROL OWNERSHIP

模型提议一步,程序控制整条循环

Proposal ≠ Execution
PROGRAM目标与初始状态声明成功条件、允许动作和总预算
PROGRAM构造 Observation只暴露当前决策所需的公开状态
MODELAction Proposal完成、调用工具或请求澄清
PROGRAM策略与参数校验拒绝越权、超预算或不合法动作
PROGRAM执行并验证得到分类结果,检查真实后置条件
COMPLETE成功条件成立提交输出和可验证证据
CONTINUE状态发生有效变化扣减预算后返回下一轮 Observation
STOP拒绝、取消或人工接管保留停止原因,不让模型覆盖程序边界
Agent 的“不确定性”只存在于候选动作选择。程序仍拥有循环入口、可执行动作集合、预算、验证器、状态提交和所有终止路径。

程序至少拥有以下控制权:

  1. 入口: 哪个用户目标可以启动哪类Run;
  2. Observation: 当前轮向模型暴露哪些公开状态;
  3. 动作集合: 本轮有哪些允许动作;
  4. 策略门: 参数、权限、预算和当前状态是否允许;
  5. 执行器: 谁真正持有凭据并调用外部系统;
  6. 验证器: 工具结果是否满足契约和真实后置条件;
  7. 状态提交: 哪些事实可以成为下一轮的权威输入;
  8. 终止: 成功、失败、取消、预算耗尽和人工接管如何进入终态。

模型可以提出:

{
"kind": "tool",
"tool": "lookup_order",
"arguments": { "orderHint": "昨天的重复扣款" }
}

程序不能直接执行这段JSON。它还要确认当前Run是否允许查订单、用户范围是否正确、参数能否解析、调用是否在Deadline内,以及返回结果能否绑定到同一个用户。

5. Agent Loop 是状态机,不是 while(true)

Section titled “5. Agent Loop 是状态机,不是 while(true)”

一个最小循环包含五个阶段:

  1. 观察(Observe):从权威状态构造本轮最小Observation。
  2. 选择(Decide):模型只返回一个候选动作、完成声明或澄清请求。
  3. 执行(Act):程序检查策略后执行一个动作,并产生分类结果。
  4. 验证(Validate):检查结果、后置条件和完成声明是否真实。
  5. 更新(Update):原子写入公开Trace与新状态,扣减预算,再决定继续或停止。

状态转换可以写成:

running + valid_proposal + allowed_action → executing
running + invalid_proposal → stopped_invalid
executing + verified_result → running
executing + unknown_effect → human_handoff
running + verified_completion → completed
running + repeated_state_or_budget_exhausted → stopped_budget
running + cancellation_requested → cancelling

关键不是状态名字,而是非法转换必须失败。例如Run进入cancelling后不能再启动新工具;模型声明complete但后置条件不成立时,不能进入completed

目标:基于三份公开文档回答“某API是否支持幂等写入”,答案必须带引用;没有证据时返回无答案。

初始预算:最多四次模型决策、三次工具调用、总Deadline 38秒。

轮次Observation摘要模型Proposal程序动作验证后状态
0尚无文档search(query)参数合法,调用检索获得三个候选来源
1三个候选,两个可能相关fetch(source-a)来源在允许列表,读取A只描述重试,没有幂等语义
2A证据不足,B仍未读取fetch(source-b)读取B并保留版本B明确说明幂等键和重复响应
3已有可引用证据complete(answer, citation)验证引用位置和结论completed

公开Trace不需要保存模型的私有思维过程。它只需记录可审计事实:

proposal.received search(query_hash=...)
policy.allowed tool=search remaining_calls=3
result.verified source_ids=[A,B,C]
proposal.received fetch(source=A)
result.verified claim_support=false
proposal.received fetch(source=B)
result.verified claim_support=true location=section-4
completion.verified citations=1 unsupported_claims=0
run.completed steps=4 tool_calls=3

如果第3轮没有引用就声明完成,验证器应拒绝完成;剩余预算允许时可以返回下一轮,否则进入stopped_budget人工接管(Human Handoff)人工接管Human Handoff自动化在证据不足、风险过高或预算耗尽时,把状态与证据交给人工继续处理。打开术语条目 →

三、用预算、副作用边界和终止条件收口

Section titled “三、用预算、副作用边界和终止条件收口”

7. 预算要同时约束时间、调用和成本

Section titled “7. 预算要同时约束时间、调用和成本”

只设置maxSteps不够。一次“Step”可能包含多个模型调用或工具调用,也可能被一个慢工具占满全部Deadline。

至少维护:

  • stepBudget:最多多少次决策循环;
  • modelCallBudget:包括重试在内的模型调用次数;
  • toolCallBudget:全部工具调用次数,必要时再按工具细分;
  • tokenBudget:输入、输出和压缩摘要的总量;
  • costBudget:模型、检索和外部API的成本上限;
  • deadline:整条Run的绝对截止时刻;
  • concurrencyBudget:并行Worker或工具的同时运行数。

假设最多四次模型决策,每次Timeout为8秒;前三轮之间最多各等待2秒退避,不考虑工具并行:

最坏延迟预算 = 4 × 8 秒 + 3 × 2 秒 = 38 秒

如果产品要求30秒内给出结果,这个配置从设计上就不可能满足目标。不能等上线后再靠平均延迟掩盖。

若工具还可能各占5秒,必须从同一个38秒Deadline中扣减,不能给模型和工具分别配置38秒。每一层收到的是“剩余时间”,不是重新开始的完整Timeout。

假设每次模型决策最多读取2,000 Token并生成300 Token,四轮上限是:

输入上限 = 4 × 2,000 = 8,000 Token
输出上限 = 4 × 300 = 1,200 Token

实际系统还要计算工具结果压缩、缓存命中和不同模型单价。预算耗尽必须进入明确终态,不能偷偷换模型继续跑。

可靠循环至少有六类终止条件。

模型说“完成”只是一个Proposal。程序要检查:必填产物是否存在、引用是否可解析、数据库状态是否到达目标、测试是否通过。只有可观察后置条件成立才能completed

例如输入违反业务规则、权限被拒绝或必要资源不存在。继续换措辞重试不会改变事实,应立即停止并返回分类错误。

步骤、调用、Token、成本或Deadline任一耗尽都停止。停止记录要说明耗尽的是哪一类预算,以及已经产生了哪些可用产物。

对规范化Proposal和关键状态生成Fingerprint。若状态没有变化却重复同一动作,继续调用模型没有新信息。

fingerprint = hash(state_version, tool_name, normalized_arguments)

同一Fingerprint连续出现两次可以触发停止;也可以维护最近N轮窗口,检测A→B→A→B振荡。不要把时间戳、随机ID这类每轮变化字段放进Fingerprint,否则循环永远检测不到。

取消是状态与信号,不是前端按钮动画。收到取消后不再启动新动作,当前调用收到AbortSignal;若外部副作用状态未知,先进入对账或人工接管,不能直接标记“已取消且什么都没发生”。

高风险、证据冲突、未知副作用或低置信路由都可以进入人工接管。Handoff至少带上当前目标、已验证事实、待确认动作、剩余预算和Trace指针,不能只显示“Agent失败”。

副作用越高,越不应放进开放循环。一个稳妥拓扑是:

  1. Read: Agent在只读工具中收集证据;
  2. Plan: 生成结构化执行计划,列出资源、参数和预期变化;
  3. Validate: 程序检查策略、当前版本和用户范围;
  4. Confirm: 用户确认准确的动作与参数;
  5. Commit: 固定Workflow执行写入;
  6. Verify: 查询真实后置条件并记录证据。

退款例子中,Agent可以选择读取哪个订单,也可以解释证据;最终refund(orderId, amount)由固定Workflow提交。确认Token必须绑定订单、金额、动作和有效期,不能用一句泛化的“确认继续”授权任意写入。

如果写入接口支持幂等(Idempotency)幂等Idempotency同一个业务意图重复提交时,不会额外产生新的业务副作用。打开术语条目 →,恢复时复用同一个业务意图身份。若Timeout后结果未知,先查询或对账;盲目换新ID重试可能产生第二次副作用。

10. 重试、取消与并行不是循环的私事

Section titled “10. 重试、取消与并行不是循环的私事”

本课前置模块已经讲过超时(Timeout)超时Timeout操作超过明确期限后主动停止等待,并进入可分类的失败路径。打开术语条目 →取消(Cancellation)取消Cancellation调用方明确通知正在执行的工作尽快停止,并释放相关资源。打开术语条目 →重试(Retry)重试Retry在可分类的临时失败后再次尝试;是否安全取决于副作用、幂等性和剩余时间。打开术语条目 →。放进Agent后,边界仍然相同,只是调用层级更多。

模型调用的网络重试也算模型调用;工具重试也算工具调用。否则“最多五步”可能在底层变成几十次请求。每次重试要检查:

  • 错误是否明确可重试;
  • 剩余Deadline是否足够;
  • 副作用是否幂等或可查询;
  • 退避是否会让整个Run超过用户等待上限。

Fan-out三个分支后,如果两个已经提供足够证据,第三个慢分支是否取消要由程序决定。若业务要求全量检查,就不能为了快而提前结束。汇总器必须区分:

complete: 3 / 3 succeeded
partial: 2 / 3 succeeded, degraded output allowed
invalid: required branch failed, no verdict

Run取消后,模型流、检索请求、Worker和工具都应收到同一个取消链。仅更新数据库状态而不停止下游,会出现“页面显示已取消,后台仍继续写入”的分裂状态。

追踪记录(Trace)追踪记录Trace跨步骤记录一次任务的时间、公开状态、调用、结果和关联身份。打开术语条目 →应回答“系统做了什么、为什么允许、结果怎样验证”,而不是记录不可验证的私有推理。

每轮最小事件:

observation.built state_version, allowed_tools, remaining_budget
proposal.received proposal_type, tool_name, arguments_hash
proposal.rejected stable_reason_code
tool.started tool_call_id, timeout, idempotency_key_hash
result.classified success | rejected | failed | unknown
result.verified postcondition, evidence_ref
state.transitioned from, to, new_version
run.stopped completion | failure | cancellation | budget | handoff

评测也要分层:

断言示例
路由退款请求没有进入普通FAQ分支
Proposal模型只选择允许工具,参数能解析
Policy无确认的写入被程序拒绝
工具超时、错误和未知副作用被正确分类
Outcome完成声明绑定真实后置条件
效率成功任务未超过调用、Token和延迟预算
恢复重放相同Trace能解释停止原因

单看最终答案会漏掉危险的“碰巧成功”:系统可能调用过越权工具,或重复写入后只展示最后一次结果。过程断言与结果断言都需要。

下面的实现只演示本章边界:模型提出一步,程序维护模型与工具预算、动作白名单、状态版本、重复检测、Timeout和完成验证。下一章会继续展开工具契约、Capability、权威状态与记忆写入。

examples/workflow-runner.runnable.ts
import assert from 'node:assert/strict';
type ToolName = 'lookup' | 'saveDraft';
interface ToolPolicy {
sideEffect: 'none' | 'write';
canQuery: boolean;
}
type ToolProposal = {
kind: 'tool';
tool: string;
arguments: Record<string, unknown>;
};
type ValidatedToolProposal = Omit<ToolProposal, 'tool'> & { tool: ToolName };
type Proposal =
| ToolProposal
| { kind: 'complete'; answer: string }
| { kind: 'clarify'; question: string };
type ToolResult =
| { status: 'success'; observation: string }
| { status: 'failed'; code: string; retryable: boolean }
| { status: 'unknown'; code: string };
type StopReason =
| 'completed'
| 'cancelled'
| 'clarification-required'
| 'invalid-proposal'
| 'repeated-proposal'
| 'step-budget-exhausted'
| 'model-call-budget-exhausted'
| 'tool-call-budget-exhausted'
| 'deadline-exhausted'
| 'model-failed'
| 'verification-failed'
| 'tool-failed'
| 'unknown-effect';
interface PublicState {
runId: string;
goal: string;
observations: string[];
version: number;
step: number;
modelCalls: number;
toolCalls: number;
}
interface TraceEvent {
type: string;
step: number;
detail: Record<string, unknown>;
}
interface Dependencies {
propose(
state: Readonly<PublicState>,
allowedTools: readonly ToolName[],
signal: AbortSignal,
): Promise<Proposal>;
invoke(proposal: ValidatedToolProposal, signal: AbortSignal): Promise<ToolResult>;
verifyCompletion(
state: Readonly<PublicState>,
answer: string,
signal: AbortSignal,
): Promise<boolean>;
now(): number;
}
interface RunOptions {
maxSteps: number;
maxModelCalls: number;
maxToolCalls: number;
modelTimeoutMs: number;
toolTimeoutMs: number;
deadlineMs: number;
signal: AbortSignal;
}
interface RunOutcome {
reason: StopReason;
answer?: string;
state: PublicState;
trace: TraceEvent[];
}
const allowedTools = ['lookup', 'saveDraft'] as const;
const toolPolicies: Record<ToolName, ToolPolicy> = {
lookup: { sideEffect: 'none', canQuery: false },
saveDraft: { sideEffect: 'write', canQuery: true },
};
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize);
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, canonicalize(nested)]);
return Object.fromEntries(entries);
}
return value;
}
function normalizeArguments(value: Record<string, unknown>): string {
return JSON.stringify(canonicalize(value));
}
function proposalFingerprint(stateVersion: number, proposal: ValidatedToolProposal): string {
return `${stateVersion}:${proposal.tool}:${normalizeArguments(proposal.arguments)}`;
}
function isAllowedTool(value: string): value is ToolName {
return allowedTools.some((tool) => tool === value);
}
class OperationTimeoutError extends Error {}
class OperationCancelledError extends Error {}
async function withTimeout<T>(
timeoutMs: number,
parentSignal: AbortSignal,
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
if (timeoutMs <= 0) throw new OperationTimeoutError('operation-timeout');
if (parentSignal.aborted) throw new OperationCancelledError('operation-cancelled');
const controller = new AbortController();
let rejectCancellation: (error: OperationCancelledError) => void = () => undefined;
const abortFromParent = () => {
controller.abort(parentSignal.reason);
rejectCancellation(new OperationCancelledError('operation-cancelled'));
};
parentSignal.addEventListener('abort', abortFromParent, { once: true });
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort('timeout');
reject(new OperationTimeoutError('operation-timeout'));
}, timeoutMs);
});
const cancellation = new Promise<never>((_, reject) => {
rejectCancellation = reject;
});
try {
return await Promise.race([operation(controller.signal), timeout, cancellation]);
} finally {
if (timer) clearTimeout(timer);
parentSignal.removeEventListener('abort', abortFromParent);
}
}
export async function runControlledAgent(
goal: string,
dependencies: Dependencies,
options: RunOptions,
): Promise<RunOutcome> {
const state: PublicState = {
runId: 'run-demo',
goal,
observations: [],
version: 0,
step: 0,
modelCalls: 0,
toolCalls: 0,
};
const trace: TraceEvent[] = [];
const seen = new Set<string>();
const deadlineAt = dependencies.now() + options.deadlineMs;
const remainingMs = () => deadlineAt - dependencies.now();
const snapshotState = (): PublicState => ({
...state,
observations: [...state.observations],
});
const stop = (reason: StopReason, answer?: string): RunOutcome => {
trace.push({
type: 'run.stopped',
step: state.step,
detail: {
reason,
stateVersion: state.version,
remainingModelCalls: Math.max(0, options.maxModelCalls - state.modelCalls),
remainingToolCalls: Math.max(0, options.maxToolCalls - state.toolCalls),
},
});
return { reason, answer, state: snapshotState(), trace: [...trace] };
};
while (state.step < options.maxSteps) {
if (options.signal.aborted) return stop('cancelled');
const modelRemainingMs = remainingMs();
if (modelRemainingMs <= 0) return stop('deadline-exhausted');
if (state.modelCalls >= options.maxModelCalls) return stop('model-call-budget-exhausted');
const modelDeadlineBound = modelRemainingMs <= options.modelTimeoutMs;
const effectiveModelTimeoutMs = Math.min(options.modelTimeoutMs, modelRemainingMs);
state.modelCalls += 1;
let proposal: Proposal;
try {
proposal = await withTimeout(effectiveModelTimeoutMs, options.signal, (signal) =>
dependencies.propose(snapshotState(), allowedTools, signal),
);
} catch (error) {
const timedOut = error instanceof OperationTimeoutError;
trace.push({
type: 'model.failed',
step: state.step,
detail: { timedOut, cancelled: options.signal.aborted },
});
if (options.signal.aborted || error instanceof OperationCancelledError) return stop('cancelled');
if (timedOut && modelDeadlineBound) return stop('deadline-exhausted');
return stop('model-failed');
}
trace.push({
type: 'proposal.received',
step: state.step,
detail: { kind: proposal.kind, modelCalls: state.modelCalls },
});
if (proposal.kind === 'clarify') return stop('clarification-required');
if (proposal.kind === 'complete') {
const verificationRemainingMs = remainingMs();
if (verificationRemainingMs <= 0) return stop('deadline-exhausted');
const verificationDeadlineBound = verificationRemainingMs <= options.toolTimeoutMs;
let verified: boolean;
try {
verified = await withTimeout(
Math.min(options.toolTimeoutMs, verificationRemainingMs),
options.signal,
(signal) => dependencies.verifyCompletion(snapshotState(), proposal.answer, signal),
);
} catch (error) {
const timedOut = error instanceof OperationTimeoutError;
trace.push({
type: 'completion.failed',
step: state.step,
detail: { timedOut, cancelled: options.signal.aborted },
});
if (options.signal.aborted || error instanceof OperationCancelledError) return stop('cancelled');
if (timedOut && verificationDeadlineBound) return stop('deadline-exhausted');
return stop('verification-failed');
}
trace.push({ type: 'completion.verified', step: state.step, detail: { verified } });
if (verified) return stop('completed', proposal.answer);
state.step += 1;
continue;
}
if (!isAllowedTool(proposal.tool)) return stop('invalid-proposal');
if (state.toolCalls >= options.maxToolCalls) return stop('tool-call-budget-exhausted');
const validatedProposal: ValidatedToolProposal = {
...proposal,
tool: proposal.tool,
arguments: canonicalize(proposal.arguments) as Record<string, unknown>,
};
const toolPolicy = toolPolicies[validatedProposal.tool];
const fingerprint = proposalFingerprint(state.version, validatedProposal);
if (seen.has(fingerprint)) return stop('repeated-proposal');
seen.add(fingerprint);
const toolRemainingMs = remainingMs();
if (toolRemainingMs <= 0) return stop('deadline-exhausted');
const toolDeadlineBound = toolRemainingMs <= options.toolTimeoutMs;
const effectiveToolTimeoutMs = Math.min(options.toolTimeoutMs, toolRemainingMs);
state.toolCalls += 1;
let result: ToolResult;
try {
result = await withTimeout(effectiveToolTimeoutMs, options.signal, (signal) =>
dependencies.invoke(validatedProposal, signal),
);
} catch (error) {
const timedOut = error instanceof OperationTimeoutError;
trace.push({
type: 'tool.failed',
step: state.step,
detail: {
tool: validatedProposal.tool,
sideEffect: toolPolicy.sideEffect,
timedOut,
cancelled: options.signal.aborted,
},
});
const cancelled = options.signal.aborted || error instanceof OperationCancelledError;
if (cancelled && toolPolicy.sideEffect === 'none') return stop('cancelled');
if (toolPolicy.sideEffect === 'write') {
trace.push({
type: 'effect.reconciliation-required',
step: state.step,
detail: {
tool: validatedProposal.tool,
trigger: cancelled ? 'cancellation' : timedOut ? 'timeout' : 'exception',
recovery: toolPolicy.canQuery ? 'query' : 'human',
},
});
return stop('unknown-effect');
}
if (timedOut && toolDeadlineBound) return stop('deadline-exhausted');
return stop('tool-failed');
}
trace.push({
type: 'result.classified',
step: state.step,
detail: { tool: validatedProposal.tool, status: result.status },
});
if (result.status === 'unknown') {
if (toolPolicy.sideEffect === 'write') {
trace.push({
type: 'effect.reconciliation-required',
step: state.step,
detail: { tool: validatedProposal.tool, recovery: toolPolicy.canQuery ? 'query' : 'human' },
});
return stop('unknown-effect');
}
return stop('tool-failed');
}
if (result.status === 'failed' && !result.retryable) return stop('tool-failed');
const observation = result.status === 'success'
? result.observation
: `tool:${validatedProposal.tool}:failed:${result.code}`;
const changed = !state.observations.includes(observation);
if (changed) {
state.observations.push(observation);
state.version += 1;
}
state.step += 1;
trace.push({
type: 'state.transitioned',
step: state.step,
detail: {
changed,
stateVersion: state.version,
observationCount: state.observations.length,
},
});
}
return stop('step-budget-exhausted');
}
function testOptions(overrides: Partial<RunOptions> = {}): RunOptions {
return {
maxSteps: 8,
maxModelCalls: 8,
maxToolCalls: 8,
modelTimeoutMs: 50,
toolTimeoutMs: 50,
deadlineMs: 500,
signal: new AbortController().signal,
...overrides,
};
}
function scriptedDependencies(
proposals: Proposal[],
results: ToolResult[],
): Dependencies {
let proposalIndex = 0;
let resultIndex = 0;
return {
async propose(_state, _allowedTools, signal) {
if (signal.aborted) throw new OperationCancelledError('model-cancelled');
const proposal = proposals[proposalIndex++];
if (!proposal) throw new Error('missing-scripted-proposal');
return proposal;
},
async invoke(_proposal, signal) {
if (signal.aborted) throw new OperationCancelledError('tool-cancelled');
const result = results[resultIndex++];
if (!result) throw new Error('missing-scripted-result');
return result;
},
async verifyCompletion(_state, _answer, signal) {
if (signal.aborted) throw new OperationCancelledError('verification-cancelled');
return true;
},
now: () => Date.now(),
};
}
async function runAcceptanceProbes(): Promise<void> {
assert.equal(
normalizeArguments({ z: { b: 2, a: 1 }, a: [{ d: 4, c: 3 }] }),
normalizeArguments({ a: [{ c: 3, d: 4 }], z: { a: 1, b: 2 } }),
);
assert.notEqual(normalizeArguments({ values: [1, 2] }), normalizeArguments({ values: [2, 1] }));
const lookup: ToolProposal = { kind: 'tool', tool: 'lookup', arguments: { query: { b: 2, a: 1 } } };
const completed = await runControlledAgent(
'collect two distinct observations',
scriptedDependencies(
[lookup, lookup, { kind: 'complete', answer: 'done' }],
[
{ status: 'success', observation: 'observation-a' },
{ status: 'success', observation: 'observation-b' },
],
),
testOptions(),
);
assert.equal(completed.reason, 'completed');
assert.equal(completed.state.version, 2);
assert.equal(completed.state.toolCalls, 2);
const repeated = await runControlledAgent(
'stop when an action no longer changes state',
scriptedDependencies(
[lookup, lookup, lookup],
[
{ status: 'success', observation: 'same-observation' },
{ status: 'success', observation: 'same-observation' },
],
),
testOptions(),
);
assert.equal(repeated.reason, 'repeated-proposal');
assert.equal(repeated.state.version, 1);
assert.equal(repeated.state.toolCalls, 2);
const modelFailureDependencies = scriptedDependencies([], []);
modelFailureDependencies.propose = async () => { throw new Error('provider-down'); };
const modelFailure = await runControlledAgent('classify model failure', modelFailureDependencies, testOptions());
assert.equal(modelFailure.reason, 'model-failed');
assert.equal(modelFailure.trace.at(-2)?.type, 'model.failed');
assert.equal(modelFailure.trace.at(-1)?.type, 'run.stopped');
const verificationFailureDependencies = scriptedDependencies(
[{ kind: 'complete', answer: 'unverified' }],
[],
);
verificationFailureDependencies.verifyCompletion = async () => {
throw new Error('verification-store-down');
};
const verificationFailure = await runControlledAgent(
'classify verification failure',
verificationFailureDependencies,
testOptions(),
);
assert.equal(verificationFailure.reason, 'verification-failed');
const modelBudget = await runControlledAgent(
'respect model budget',
scriptedDependencies([], []),
testOptions({ maxModelCalls: 0 }),
);
assert.equal(modelBudget.reason, 'model-call-budget-exhausted');
const uncertainRead = await runControlledAgent(
'do not invent a side effect for a read',
scriptedDependencies([lookup], [{ status: 'unknown', code: 'read-response-lost' }]),
testOptions(),
);
assert.equal(uncertainRead.reason, 'tool-failed');
const isolationDependencies = scriptedDependencies([], []);
isolationDependencies.propose = async (state) => {
(state as PublicState).observations.push('attempted-external-mutation');
return { kind: 'clarify', question: 'need input' };
};
const isolated = await runControlledAgent('isolate dependency input', isolationDependencies, testOptions());
assert.deepEqual(isolated.state.observations, []);
const deadlineDependencies = scriptedDependencies([lookup], []);
deadlineDependencies.invoke = async () => await new Promise<ToolResult>(() => undefined);
const deadline = await runControlledAgent(
'bound a read by remaining run time',
deadlineDependencies,
testOptions({ deadlineMs: 10, toolTimeoutMs: 100 }),
);
assert.equal(deadline.reason, 'deadline-exhausted');
const writeProposal: ToolProposal = { kind: 'tool', tool: 'saveDraft', arguments: { draftId: 'draft-1' } };
const adapterUnknownWrite = await runControlledAgent(
'preserve recovery metadata for an adapter-reported unknown write',
scriptedDependencies([writeProposal], [{ status: 'unknown', code: 'write-response-lost' }]),
testOptions(),
);
assert.equal(adapterUnknownWrite.reason, 'unknown-effect');
assert.equal(
adapterUnknownWrite.trace.some(
(event) => event.type === 'effect.reconciliation-required' && event.detail.recovery === 'query',
),
true,
);
const uncertainWriteDependencies = scriptedDependencies([writeProposal], []);
uncertainWriteDependencies.invoke = async () => await new Promise<ToolResult>(() => undefined);
const uncertainWrite = await runControlledAgent(
'classify uncertain write by metadata',
uncertainWriteDependencies,
testOptions({ deadlineMs: 100, toolTimeoutMs: 5 }),
);
assert.equal(uncertainWrite.reason, 'unknown-effect');
assert.equal(
uncertainWrite.trace.some((event) => event.type === 'effect.reconciliation-required'),
true,
);
const writeCancellationController = new AbortController();
const cancelledWriteDependencies = scriptedDependencies([writeProposal], []);
cancelledWriteDependencies.invoke = async () => await new Promise<ToolResult>(() => {
setTimeout(() => writeCancellationController.abort('cancel-during-write'), 1);
});
const cancelledWrite = await runControlledAgent(
'reconcile a write cancelled in flight',
cancelledWriteDependencies,
testOptions({ signal: writeCancellationController.signal }),
);
assert.equal(cancelledWrite.reason, 'unknown-effect');
assert.equal(
cancelledWrite.trace.some(
(event) => event.type === 'effect.reconciliation-required' && event.detail.trigger === 'cancellation',
),
true,
);
const readCancellationController = new AbortController();
const cancelledReadDependencies = scriptedDependencies([lookup], []);
cancelledReadDependencies.invoke = async () => await new Promise<ToolResult>(() => {
setTimeout(() => readCancellationController.abort('cancel-during-read'), 1);
});
const cancelledRead = await runControlledAgent(
'cancel a read in flight',
cancelledReadDependencies,
testOptions({ signal: readCancellationController.signal }),
);
assert.equal(cancelledRead.reason, 'cancelled');
const cancelledController = new AbortController();
cancelledController.abort('user-cancelled');
const cancelled = await runControlledAgent(
'stop before any new work',
scriptedDependencies([], []),
testOptions({ signal: cancelledController.signal }),
);
assert.equal(cancelled.reason, 'cancelled');
assert.equal(cancelled.state.modelCalls, 0);
}
await runAcceptanceProbes();

这个接口把Proposal视为已经通过基础Schema解析的候选;真实模型Adapter必须先把原始输出当作unknown解析,拒绝缺字段、错类型和多余危险字段。Runner随后再用isAllowedTool把任意工具名收窄到当前Run允许的动作集合。

参数规范化会递归排序对象键,但保留数组顺序,因为数组通常表达有序参数;如果某个数组在业务上是集合,应由该工具的Schema先定义去重和排序规则。模型与工具Adapter的一次调用必须对应一次可计费尝试,不能在内部隐藏不扣预算的重试。

代码末尾的十四组Acceptance Probe会实际检查深层规范化、状态变化后的合法重复、无进展循环、模型错误、完成验证错误、模型调用预算、只读未知结果、依赖输入隔离、Run Deadline、Adapter报告的未知写入、Timeout后的未知写入、执行中取消写入、执行中取消读取与预先取消。仓库的Snippet Gate会先用TypeScript strict编译,再真正执行它:

Terminal window
pnpm verify:snippets

这个例子故意没有把所有生产问题都塞进一个文件。它仍需在下一章补上:工具输入输出Schema、权限Capability、稳定幂等身份、持久Run State、并发版本、长期记忆和副作用对账。

现象首先检查常见根因正确处理
同类输入偶尔走错流程Router标签与混淆矩阵未知类别被强制归类、标签定义漂移增加unknown,按高风险类别单独测误路由
并行任务一直等不完每个分支状态和汇总条件要求全成功但某分支无Deadline定义部分失败语义,取消非必要慢分支
评价循环反复改写每轮分数和差异无最大轮数、无最小改进阈值预算耗尽或连续无改进时停止
工具一直重复调用Proposal Fingerprint和状态版本工具结果未进入状态,或随机字段破坏去重修复状态提交,规范化Fingerprint后停止
模型称完成但产物缺失completion验证事件把自然语言声明当后置条件由程序查询产物、引用或业务状态
用户取消后仍发生写入下游Abort和工具状态只改UI或Run状态,没有传播取消停止新动作,取消当前调用并验证副作用
Timeout后产生重复写入幂等身份和对账查询把未知结果当明确失败并换ID重试先查询真实状态,恢复时复用意图身份
成功率尚可但成本暴涨每Run调用数、Token和并发底层重试不计预算、Worker递归创建所有调用共用总预算和并发上限
Trace看不出为何停止run.stopped原因码只记录自由文本或最终答案记录稳定原因、预算快照和证据指针

所有实验都可以使用固定Proposal序列和假工具完成,不需要真实模型。

准备20个带标签请求,其中5个是退款,5个是普通账单解释。让Router把一个低置信退款请求输出为billing_faq

断言: 高风险分支误路由被单独报告;低置信结果进入unknown或人工分类,不能直接调用写工具。

Fan-out三个文档读取器:A在100毫秒返回,B返回可重试错误,C永不返回。

断言: C在分支Deadline时被取消;如果策略允许两份证据,结果标记partial并列出缺失来源;如果必须全量,整体进入invalid而不是伪装成功。

固定Proposal第一轮就返回complete,但Artifact存储中不存在目标文件。

断言: verifyCompletion拒绝完成;Trace出现completion.verified=false;剩余预算允许时进入下一轮,预算不足时明确停止。

saveDraft先写入记录,再让响应Timeout。

断言: 结果分类为unknown;Runner不换幂等身份重试;先用只读查询确认记录是否存在,再进入完成、补偿或人工接管。

Planner连续两轮返回同一工具、同一规范化参数,而权威状态版本没有变化。

断言: 第二次执行前触发repeated-proposal;工具实际只调用一次;停止事件包含Fingerprint和剩余预算。

交互实验 · 确定性模拟

Agent Loop 故障注入

沿图逐步执行;一旦越过安全边界,路径会转入停止与恢复。

权限 · SCHEMA · 超时STATE公开状态INPUT状态改变后再观察1观察2选择3工具4验证5更新完成后输出

STEP 1 / 5

观察输入

读取用户目标、当前状态和可用工具。

公开输出结构化任务 + 状态快照

当前步骤:观察输入

  • 工具超时时,当前动作必须停止,而不是假装成功;
  • Schema不合法时,结果不能进入下一状态;
  • 状态重复时,要触发循环保护;
  • Trace记录公开决策、工具调用和验证结果,不记录模型私有思维过程;
  • 重新开始会恢复初始状态,不继承上个场景的故障。

在引入Agent框架前,逐项回答:

  • 哪些步骤永远相同,为什么不写成固定Workflow?
  • 模型究竟在哪些节点选择下一步?
  • 每个动态节点的允许动作集合是什么?
  • 是否存在默认安全分支和unknown状态?
  • 每个工具结果怎样验证?
  • 完成条件能否由程序检查?
  • 哪些动作只读、幂等、可补偿或不可逆?
  • 高风险写入是否离开开放循环并要求精确确认?
  • Step、模型调用、工具调用、Token、成本和Deadline是否分别计数?
  • 底层重试是否也扣减总预算?
  • 重复状态、无进展和评价器振荡怎样检测?
  • 取消后谁负责确认外部副作用真实状态?
  • Trace能否解释每个Proposal为何允许或拒绝?
  • Worker部分失败如何汇总?
  • Timeout后的未知结果如何对账?
  • 人工接管能否获得当前状态、已验证证据和待处理动作?

如果这些问题没有答案,增加Agent框架只会把未知边界藏进更多抽象层。

实现一个不调用真实服务的TypeScript Runner:

  1. 支持lookupsaveDraft两个工具;
  2. Planner由固定Proposal数组驱动;
  3. 每个动作先过允许工具与参数校验;
  4. saveDraft使用稳定业务意图身份;
  5. 最大五步、三次工具调用和一个绝对Deadline;
  6. 连续两次Proposal Fingerprint相同就停止;
  7. complete必须通过独立后置条件验证;
  8. 输出Trace JSON和分类停止原因。

至少写六个测试:

  • 正常路径完成;
  • 非法Proposal在执行前被拒绝;
  • 工具Timeout且确认没有副作用;
  • 工具Timeout但副作用未知,进入对账;
  • 重复Proposal只调用一次工具;
  • 用户取消后不再启动新动作。

验收:六个测试全部通过;Trace能解释每个停止原因;没有未捕获Promise rejection;同一业务意图恢复时不会产生第二次写入。

CHECK YOUR MODEL

哪种任务更适合固定 Workflow?

选择最适合固定 Workflow 的任务
  1. 为什么固定Workflow并不比Agent“低级”?
  2. Routing、并行、Evaluator–Optimizer和Agent Loop分别把什么交给模型?
  3. 如何把一个高副作用任务拆成只读Agent区和固定提交区?
  4. 为什么模型声明完成不等于系统完成?
  5. 为什么maxSteps不能替代模型调用、工具调用、成本和Deadline预算?
  6. 如何检测同动作循环与A→B→A→B振荡?
  7. Timeout后为什么可能需要对账而不是重试?
  8. 多Agent什么时候只是复制不确定性?
  9. Trace应记录哪些公开事实,哪些内容不需要记录?
  10. 哪些信号应该触发人工接管?

本章解决的是:什么时候允许模型选择下一步,以及怎样让编排循环可停止、可验证。

下一章状态、工具与记忆继续解决:

  • 工具输入输出、权限、副作用和错误如何形成可执行契约;

  • Action Proposal怎样变成Executable Command;

  • 权威Run State、Working Memory、Long-term Memory和Trace怎样分离;

  • 记忆写入、Capability、并发版本和副作用Ledger怎样实现。

  • 回到完整学习路线

  • 查看方向总览

  • 检查来源与版本