“模型决定调用工具,工具结果再交给模型”只描述了 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<I, O> = {
name: string;
validate(input: unknown): I;
readOnly: boolean;
concurrencySafe: boolean;
timeoutMs: number;
approval: "never" | "on-write" | "always";
execute(input: I, signal: AbortSignal): Promise<O>;
};
默认值应保守:未声明只读就按写操作处理,未声明并发安全就串行执行。参数校验必须发生在权限判断之前,避免攻击者用畸形路径绕过规则。
对有副作用的工具增加幂等键:
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.example
LLM_API_KEY=replace_me
LLM_MODEL=your-tested-model
这样做的目的不是承诺“随时切换”,而是让兼容性测试有固定入口。不同服务在鉴权头、流式事件、工具调用字段、错误码和上下文限制上可能不同,必须逐项验证。作为候选之一,haerapi.com 的公开首页将自身定位为 “AI API Gateway”;团队在试用前仍应自行核对协议兼容、可用模型、鉴权方式、数据处理、计费与服务条款,不能仅凭名称接入生产。
一个可恢复的执行骨架
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.save({ 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 才从演示程序变成可以运营和审计的系统。