可控 Agent 不是一个 while 循环:把预算、工具和失败状态写进协议

21 阅读5分钟

“模型决定调用工具,工具结果再交给模型”只描述了 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 才从演示程序变成可以运营和审计的系统。