“模型决定调用工具,工具结果再交给模型”——这个描述仅覆盖了Agent的最小循环,远远无法应对生产环境中那些棘手的现实问题:重试机制、幂等性保障、权限控制、成本管理,以及最关键的人工干预。本文从状态机出发,梳理如何构建一个真正可控的Agent,并提供工具契约、执行预算、上游适配和故障注入的具体方法,让每一次动作都能被解释、限制和恢复。

最小循环为何会失控
演示代码的写法通常是这样:模型返回工具调用就执行,否则结束。但这里面藏着几个危险假设——模型总会收敛、工具可以无限制重复执行、参数天然合法、上游失败只需要重试、历史消息可以无限增长。生产环境必须把这些假设变成显式约束,不能有任何模糊地带。
一个任务至少应该具备以下这些终态:
type RunStatus =| "completed"| "budget_exhausted"| "waiting_for_approval"| "tool_failed"| "provider_failed"| "cancelled"
“失败”不能只有一个布尔值。工具失败可以换参数重新尝试,上游超时也有有限的重试次数,权限不足时必须等待人工授权,预算耗尽则应该生成阶段性结果并优雅停止。每一种状态都对应不同的处理策略。
用预算包住循环
预算不仅要设置轮次上限,还要同时考虑墙钟时间、模型调用次数、工具调用次数和输出大小。单一的 maxSteps 根本阻止不了一次工具调用运行半小时——那是赤裸裸的生产事故。
type Budget = {deadline: number;modelCallsLeft: number;toolCallsLeft: number;outputBytesLeft: number;};function assertBudget(b: Budget) {if (Date.now() >= b.deadline) throw new Error("DEADLINE_EXCEEDED");if (b.modelCallsLeft <= 0) throw new Error("MODEL_BUDGET_EXHAUSTED");if (b.toolCallsLeft <= 0) throw new Error("TOOL_BUDGET_EXHAUSTED");}
需要注意,每次执行前就要扣预算,而不是成功之后再扣——否则超时和异常会绕过计数,导致预算形同虚设。预算值应该由任务类型决定,只读问答和代码迁移显然不应该共享同一个上限。
工具契约要比函数签名更完整
工具的声明不能只写一个函数签名,至少需要包含输入模式、读写属性、并发安全性、超时和审批策略:
type ToolSpec = {name: string;validate(input: unknown): I;readOnly: boolean;concurrencySafe: boolean;timeoutMs: number;approval: "never" | "on-write" | "always";execute(input: I, signal: AbortSignal): Promise;};
默认值应该尽可能保守:未声明只读就按写操作处理,未声明并发安全就串行执行。参数校验必须在权限判断之前完成,否则攻击者可能用畸形路径绕过规则。
对于有副作用的工具,增加幂等键是必要的:
const idempotencyKey = `${runId}:${stepId}:${toolName}`;const previous = await resultStore.get(idempotencyKey);if (previous !== undefined) return previous;const result = await tool.execute(input, signal);await resultStore.put(idempotencyKey, result);return result;
当然,这不能替代上游自身的幂等能力。像付款、发信和删改数据这样的关键动作,仍然需要服务端用唯一约束或事务来保证“最多一次”。
把模型接入层做成窄接口
Agent 不应该直接依赖某个 SDK 的完整响应对象。先归一化为内部事件:
type ModelEvent =| { type: "text"; text: string }| { type: "tool_call"; id: string; name: string; input: unknown }| { type: "usage"; inputTokens: number; outputTokens: number }| { type: "stop"; reason: string };interface ModelGateway {stream(request: ModelRequest, signal: AbortSignal): AsyncIterable<ModelEvent>;}
具体上游通过环境配置注入,例如:
LLM_BASE_URL=https://your-gateway.exampleLLM_API_KEY=replace_meLLM_MODEL=your-tested-model
这样做的目的不是承诺“随时切换”,而是让兼容性测试有一个固定的入口。不同服务在鉴权头、流式事件、工具调用字段、错误码和上下文限制上差异很大,必须逐项验证。
一个可恢复的执行骨架
async function runAgent(ctx: RunContext): Promise<RunStatus> {while (true) {if (ctx.signal.aborted) return "cancelled";try {assertBudget(ctx.budget);} catch {return "budget_exhausted";}ctx.budget.modelCallsLeft--;let decision: ModelDecision;try {decision = await ctx.model.decide(ctx.messages, ctx.signal);} catch {return "provider_failed";}if (decision.type === "final") {await ctx.events.append({ type: "final", text: decision.text });return "completed";}const tool = ctx.tools.get(decision.name);if (!tool) return "tool_failed";const input = tool.validate(decision.input);if (tool.approval === "always" || (tool.approval === "on-write" && !tool.readOnly)) {await ctx.checkpoints.sa ve({ decision, input });return "waiting_for_approval";}ctx.budget.toolCallsLeft--;let result: ToolResult;try {result = await executeWithTimeout(tool, input, ctx.signal);} catch {return "tool_failed";}ctx.messages.push(toToolResult(decision.id, result));}}
人工批准后从 checkpoint 恢复,而不是让模型重新规划。重新规划可能生成不同参数,也会让审批对象失效,这一点尤其重要。
重试只对“可证明瞬时”的错误开放
适合自动重试的通常是连接重置、限流和明确的 5xx;参数错误、鉴权失败和内容策略拒绝则不应该盲目重试。指数退避要加入随机抖动,并受总 deadline 约束:
const delay = Math.min(4000, 250 * 2 ** attempt) * (0.5 + Math.random());if (Date.now() + delay >= budget.deadline) throw error;await sleep(delay, signal);
流式响应的情况更棘手:连接断开前已经输出的内容可能无法安全续传。除非协议提供可恢复游标,否则应该把该轮标为不完整,重新发起新轮次并在 UI 中替换旧草稿,而不是直接拼接。
上线前做故障注入
以下是一些值得提前模拟的场景:
- 模型连续返回不存在的工具名。
- 同一个写工具调用被投递两次。
- 工具在产生副作用后超时。
- 流式响应在半个 JSON 参数处断开。
- 审批等待期间任务被取消。
- 上下文压缩后丢失关键权限信息。
断言的不仅仅是“没有崩溃”,还包括预算未穿透、审计事件连续、重复动作未执行,以及用户能看到明确的终态。
总结
Agent 的核心不是循环,而是循环外的约束系统。预算限制它能走多远,工具契约限制它能做什么,幂等和 checkpoint 决定失败后能否恢复,窄接入层则让上游差异变得可测试。把这些状态写进协议,Agent 才从演示程序变成真正可以运营和审计的系统。
