CODE-DRIVEN
固定 Workflow
程序提前决定步骤、分支和停止条件。
- 读取输入
- 运行固定步骤
- 检查明确条件
- 返回或进入人工处理
假设系统收到一句话:“昨天重复扣款了,帮我退款。”它可能需要:
表面看,这是一串步骤,似乎适合固定工作流(Workflow)工作流Workflow由代码预先决定步骤、分支和停止条件的执行路径。打开术语条目 →。但真实输入可能缺少订单号、一个用户可能有多笔订单、不同渠道规则不同,检索结果也可能冲突。路径中存在需要根据现场状态选择下一步的部分。
错误的结论是:“既然有不确定性,就让 Agent 自己处理全部流程。”退款具有外部副作用,错误动作难以撤回;如果模型可以直接调用支付接口,它可能选错订单、重复退款、把超时误判为失败后再次提交,或者在证据不足时仍宣称完成。
更可靠的拆分是:
这不是“Workflow 或 Agent 二选一”。生产系统通常是 Workflow 包围一个受控 Agent 区域。
CODE-DRIVEN
程序提前决定步骤、分支和停止条件。
MODEL-DIRECTED, CODE-GUARDED
模型选择下一项动作,程序控制权限、预算和终止。
从单次模型调用到受控 Agent,中间至少有六种常见模式。差别不在于用了多少个 Prompt,而在于下一步由谁选择、结果由谁合并、循环由谁停止。
| 模式 | 模型决策点 | 程序必须拥有的控制 | 适用问题 | 主要失败路径 |
|---|---|---|---|---|
| 单次调用 | 0–1 | 输入、输出Schema和失败处理 | 分类、抽取、改写 | 输出不合法、事实错误 |
| Prompt Chaining | 每个固定阶段一次 | 顺序、阶段输入和停止 | 先抽取再生成、先计划再格式化 | 上游错误向后传播 |
| Routing | 选择一个分支 | 路由标签、默认分支和回退 | 按意图选择工具或专家 | 路由漂移、未知类别 |
| Parallel Fan-out/Fan-in | 多个独立子任务 | 并发上限、合并规则和部分失败策略 | 多来源检索、独立检查 | 慢节点拖尾、结果冲突 |
| Evaluator–Optimizer | 生成与评价交替 | 通过条件、轮数和改进阈值 | 文案修订、代码修复 | 评价器偏差、无限互改 |
| Orchestrator–Workers | 动态拆分子任务 | Worker边界、依赖图和汇总验证 | 边界可分的复杂调查 | 重复工作、依赖遗漏 |
| 受控 Agent Loop | 每轮选择下一动作 | 动作集合、预算、验证、状态和终止 | 路径无法预先枚举 | 循环、越权、成本失控 |
程序提前知道阶段:例如“抽取字段 → 检索证据 → 生成带引用答案”。模型只在每个阶段完成受限转换。若第二阶段失败,程序知道停在哪里,也能单独重放。
不要为了“更Agent”而让模型重新决定是否需要抽取或检索;如果步骤总是相同,动态选择只增加故障面。
Router 输出有限标签,例如 billing、technical、account、unknown。程序把标签映射到固定分支。unknown 是必要状态,不应该把所有低置信输入硬塞进最相近分支。
Router 的正确率不能只看整体 Accuracy。高风险分支还要单独检查误路由率:把普通咨询路由到只读FAQ通常可恢复,把退款请求路由到可写支付工具则需要更严格的后续门槛。
并行适合互相独立的工作:同时读取三份公开材料,或让三个确定性检查器分别验证格式、引用和安全边界。Fan-in 必须定义:
“开十个Agent一起想”不是并行策略。没有独立边界和合并规则,只会把一个不确定任务复制十次。
生成器产出候选,评价器给出结构化缺口,生成器据此修订。循环的通过条件必须由程序读取,例如 allRequiredCitationsPresent === true,而不是评价器写一句“现在很好”。还要设置最大轮数和最小改进量;连续两轮得分不变应停止,而不是继续消耗预算。
Orchestrator 适合在运行时拆出边界清楚的子任务,例如分别比较三个API的认证、限流和错误语义。Worker不应再无限创建Worker;依赖和汇总仍由程序检查。
多Agent是一种拓扑,不是能力证明。若一个模型加三个工具已经能完成任务,增加角色不会自动提高正确率。
先不要问“哪个框架最流行”,而要问下面四个问题。
如果完成条件可以由Schema、数据库查询、测试或外部状态验证,模型选择动作后仍能由程序收口。若结果主要依赖主观判断,要增加人工评价或多层评测,而不是让同一个模型既生成又宣布自己成功。
| 副作用 | 默认策略 |
|---|---|
| 无副作用读取 | 可在预算内自动执行 |
| 可重放计算 | 可自动重试,但保留输入和版本 |
| 幂等写入 | 使用稳定幂等键并验证后置条件 |
| 可补偿写入 | 记录原状态和补偿动作,失败时对账 |
| 不可逆或高影响操作 | 固定流程、明确确认、最小权限和人工接管 |
工具返回200不代表业务目标成立;Timeout也不代表外部写入没有发生。越难观察真实后置条件,越不能把控制权交给开放循环。
ARCHITECTURE COMPASS
沿两条轴移动任务:路径越未知,越需要模型决策;副作用越高,越需要固定流程包围。
路径已经足够清楚,让代码决定步骤、分支和停止条件。
高风险步骤仍需权限检查、确认、幂等和审计。智能体(Agent)智能体Agent在程序边界内,由模型根据当前状态选择下一步动作的系统。打开术语条目 →的定义不是“模型可以做任何事”,而是“模型在程序边界内选择下一步”。模型输出是候选,不是已经授权的命令。
CONTROL OWNERSHIP
程序至少拥有以下控制权:
模型可以提出:
{ "kind": "tool", "tool": "lookup_order", "arguments": { "orderHint": "昨天的重复扣款" }}程序不能直接执行这段JSON。它还要确认当前Run是否允许查订单、用户范围是否正确、参数能否解析、调用是否在Deadline内,以及返回结果能否绑定到同一个用户。
一个最小循环包含五个阶段:
状态转换可以写成:
running + valid_proposal + allowed_action → executingrunning + invalid_proposal → stopped_invalidexecuting + verified_result → runningexecuting + unknown_effect → human_handoffrunning + verified_completion → completedrunning + repeated_state_or_budget_exhausted → stopped_budgetrunning + cancellation_requested → cancelling关键不是状态名字,而是非法转换必须失败。例如Run进入cancelling后不能再启动新工具;模型声明complete但后置条件不成立时,不能进入completed。
目标:基于三份公开文档回答“某API是否支持幂等写入”,答案必须带引用;没有证据时返回无答案。
初始预算:最多四次模型决策、三次工具调用、总Deadline 38秒。
| 轮次 | Observation摘要 | 模型Proposal | 程序动作 | 验证后状态 |
|---|---|---|---|---|
| 0 | 尚无文档 | search(query) | 参数合法,调用检索 | 获得三个候选来源 |
| 1 | 三个候选,两个可能相关 | fetch(source-a) | 来源在允许列表,读取 | A只描述重试,没有幂等语义 |
| 2 | A证据不足,B仍未读取 | fetch(source-b) | 读取B并保留版本 | B明确说明幂等键和重复响应 |
| 3 | 已有可引用证据 | complete(answer, citation) | 验证引用位置和结论 | completed |
公开Trace不需要保存模型的私有思维过程。它只需记录可审计事实:
proposal.received search(query_hash=...)policy.allowed tool=search remaining_calls=3result.verified source_ids=[A,B,C]proposal.received fetch(source=A)result.verified claim_support=falseproposal.received fetch(source=B)result.verified claim_support=true location=section-4completion.verified citations=1 unsupported_claims=0run.completed steps=4 tool_calls=3如果第3轮没有引用就声明完成,验证器应拒绝完成;剩余预算允许时可以返回下一轮,否则进入stopped_budget或人工接管(Human Handoff)人工接管Human Handoff自动化在证据不足、风险过高或预算耗尽时,把状态与证据交给人工继续处理。打开术语条目 →。
只设置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失败”。
副作用越高,越不应放进开放循环。一个稳妥拓扑是:
退款例子中,Agent可以选择读取哪个订单,也可以解释证据;最终refund(orderId, amount)由固定Workflow提交。确认Token必须绑定订单、金额、动作和有效期,不能用一句泛化的“确认继续”授权任意写入。
如果写入接口支持幂等(Idempotency)幂等Idempotency同一个业务意图重复提交时,不会额外产生新的业务副作用。打开术语条目 →,恢复时复用同一个业务意图身份。若Timeout后结果未知,先查询或对账;盲目换新ID重试可能产生第二次副作用。
本课前置模块已经讲过超时(Timeout)超时Timeout操作超过明确期限后主动停止等待,并进入可分类的失败路径。打开术语条目 →、取消(Cancellation)取消Cancellation调用方明确通知正在执行的工作尽快停止,并释放相关资源。打开术语条目 →与重试(Retry)重试Retry在可分类的临时失败后再次尝试;是否安全取决于副作用、幂等性和剩余时间。打开术语条目 →。放进Agent后,边界仍然相同,只是调用层级更多。
模型调用的网络重试也算模型调用;工具重试也算工具调用。否则“最多五步”可能在底层变成几十次请求。每次重试要检查:
Fan-out三个分支后,如果两个已经提供足够证据,第三个慢分支是否取消要由程序决定。若业务要求全量检查,就不能为了快而提前结束。汇总器必须区分:
complete: 3 / 3 succeededpartial: 2 / 3 succeeded, degraded output allowedinvalid: required branch failed, no verdictRun取消后,模型流、检索请求、Worker和工具都应收到同一个取消链。仅更新数据库状态而不停止下游,会出现“页面显示已取消,后台仍继续写入”的分裂状态。
追踪记录(Trace)追踪记录Trace跨步骤记录一次任务的时间、公开状态、调用、结果和关联身份。打开术语条目 →应回答“系统做了什么、为什么允许、结果怎样验证”,而不是记录不可验证的私有推理。
每轮最小事件:
observation.built state_version, allowed_tools, remaining_budgetproposal.received proposal_type, tool_name, arguments_hashproposal.rejected stable_reason_codetool.started tool_call_id, timeout, idempotency_key_hashresult.classified success | rejected | failed | unknownresult.verified postcondition, evidence_refstate.transitioned from, to, new_versionrun.stopped completion | failure | cancellation | budget | handoff评测也要分层:
| 层 | 断言示例 |
|---|---|
| 路由 | 退款请求没有进入普通FAQ分支 |
| Proposal | 模型只选择允许工具,参数能解析 |
| Policy | 无确认的写入被程序拒绝 |
| 工具 | 超时、错误和未知副作用被正确分类 |
| Outcome | 完成声明绑定真实后置条件 |
| 效率 | 成功任务未超过调用、Token和延迟预算 |
| 恢复 | 重放相同Trace能解释停止原因 |
单看最终答案会漏掉危险的“碰巧成功”:系统可能调用过越权工具,或重复写入后只展示最后一次结果。过程断言与结果断言都需要。
下面的实现只演示本章边界:模型提出一步,程序维护模型与工具预算、动作白名单、状态版本、重复检测、Timeout和完成验证。下一章会继续展开工具契约、Capability、权威状态与记忆写入。
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编译,再真正执行它:
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和剩余预算。
交互实验 · 确定性模拟
沿图逐步执行;一旦越过安全边界,路径会转入停止与恢复。
STEP 1 / 5
读取用户目标、当前状态和可用工具。
当前步骤:观察输入
在引入Agent框架前,逐项回答:
unknown状态?如果这些问题没有答案,增加Agent框架只会把未知边界藏进更多抽象层。
实现一个不调用真实服务的TypeScript Runner:
lookup与saveDraft两个工具;saveDraft使用稳定业务意图身份;complete必须通过独立后置条件验证;至少写六个测试:
验收:六个测试全部通过;Trace能解释每个停止原因;没有未捕获Promise rejection;同一业务意图恢复时不会产生第二次写入。
CHECK YOUR MODEL
maxSteps不能替代模型调用、工具调用、成本和Deadline预算?本章解决的是:什么时候允许模型选择下一步,以及怎样让编排循环可停止、可验证。
下一章状态、工具与记忆继续解决: