试读章节:Agent Loop 的完整生命周期
这篇放一段《Agent Runtime 工程化》的试读。
不是整章原样贴。
我把第三章里最核心的部分抽出来,重新整理成一篇社区版:一次 Agent Loop 到底应该怎么跑。
很多 Agent 教程会把主循环写成:
用户输入
调用模型
模型调用工具
工具结果返回模型
模型输出答案
这张图没错。
只是太薄。
真正写 Runtime 时,你会发现每一步都有边界。
模型不是直接执行者。模型只是提出下一步。
Runtime 才负责校验、审批、执行、记录和停止。
一次 Agent Loop 不只是 while
最小循环可以写得很短:
while (step < maxSteps) {
const response = await model.complete(messages, tools);
if (response.final) return response.text;
for (const call of response.toolCalls) {
const result = await tools[call.name](call.input);
messages.push(toToolMessage(result));
}
}
教学时,这段代码很好。
但生产里,它至少漏了这些问题:
工具名不存在怎么办?
参数缺字段怎么办?
路径越过 workspace 怎么办?
写文件是否需要用户批准?
工具输出太大怎么办?
工具失败后能不能重试?
这一步是否写入 trace?
中断后从哪里恢复?
为什么最终停止?
所以本书里,我更愿意把 Agent Loop 看成一个生命周期,而不是一个 while loop。
第一步:创建 Run
用户看到一句话:
帮我修复这个测试失败。
Runtime 看到的是一次 run。
它至少要创建:
type RuntimeIds = {
sessionId: string;
runId: string;
turnId: string;
traceId: string;
};
如果没有这些 ID,后面所有东西都串不起来。
你不知道某个 tool result 属于哪次用户请求。
不知道 checkpoint 对应哪次运行。
不知道 trace 里第 7 步为什么出现一个写文件操作。
也不知道用户取消的是哪个任务。
Agent Runtime 的第一步,不是调用模型。
是给这次执行建立身份。
第二步:构建上下文
Context Builder 负责本轮模型看见什么。
它不应该简单把所有历史拼起来。
它要选择:
当前用户目标
安全策略
最近消息
当前 run state
可用工具
相关文件片段
工具 observation
历史摘要
预算报告
一个最小上下文结果可以是:
type ContextPackage = {
messages: RuntimeMessage[];
digest: {
messageCount: number;
estimatedTokens: number;
includedFiles: string[];
droppedItems: string[];
budgetLimit: number;
};
};
digest 很重要。
模型答错时,你要能查:
它有没有看见正确文件?
用户约束有没有被挤掉?
工具结果是否被截断?
repo map 是否误导了它?
没有 context digest,排障时只能说'模型忘了'。
这句话没法修 bug。
第三步:调用模型
模型调用应该通过 Runtime 自己的中间表示。
不要把某个 SDK 的原始字段撒到系统各处。
本书用的核心类型是:
type ModelResponse =
| { kind: "final"; text: string; usage?: TokenUsage }
| { kind: "tool_calls"; calls: ToolCall[]; usage?: TokenUsage };
type ToolCall = {
id: string;
name: string;
input: unknown;
};
模型可能返回最终答案,也可能返回一组 tool calls。
Runtime 要记住一件事:
模型输出不是可信事实。
它是一份待校验的计划。
第四步:校验工具调用
工具系统不能只是:
tools[call.name](call.input)
Runtime 必须先通过 Tool Registry 生成计划。
class ToolRegistry {
plan(call: ToolCall): ToolPlan {
const tool = this.tools.get(call.name);
if (!tool) {
return { call, validationErrors: ["UNKNOWN_TOOL"] };
}
const validation = validateObjectSchema(tool.inputSchema, call.input);
if (!validation.ok) {
return { call, tool, validationErrors: validation.errors };
}
return { call, tool, validationErrors: [] };
}
}
工具不存在,不能执行。
参数形状不对,不能执行。
参数形状对,也不一定能执行。
比如:
{"path":"../../.ssh/id_rsa"}
它可能符合 schema,因为 path 是字符串。
但它不符合 workspace policy。
所以 schema validation 后面还要有 policy validation。
第五步:权限审批
Permission Gate 要在 execute 前运行。
顺序不能反。
一个最小决策对象可以是:
type PermissionDecision = {
callId: string;
toolName: string;
action: "allow" | "deny" | "ask";
reason: string;
risk: "read" | "write" | "shell" | "network" | "dangerous";
decidedAt: string;
preview?: string;
};
只读工具可以自动执行。
写文件要给 preview。
shell 要走命令策略。
网络工具要说明域名和用途。
破坏性操作默认拒绝。
权限审批不是 UI 小插曲。它是执行历史的一部分,必须进入 trace 和 checkpoint。
否则恢复执行时,Runtime 可能忘记用户已经拒绝过某个危险动作。
第六步:执行工具
工具执行时,Runtime 要控制:
timeout
AbortSignal
输出上限
错误分类
是否可重试
是否并行
是否有副作用
工具结果不应该只是一段字符串。
type ToolResult = {
callId: string;
toolName: string;
status: "ok" | "error" | "denied" | "timeout" | "cancelled";
content: string;
errorCode?: string;
metadata: {
durationMs: number;
outputChars: number;
truncated: boolean;
retryable: boolean;
};
};
content 给模型看。
metadata 给 Runtime 和 trace 看。
如果工具输出被截断,模型必须知道。
如果错误可以重试,Runtime 和模型都要知道。
如果工具被拒绝,不能伪装成普通失败。
这就是结构化 observation 的价值。
第七步:写回观察结果
工具结果要进入 message history。
但不是随便拼一句:
tool returned: ...
它应该作为 RuntimeMessage 写回:
type RuntimeMessage =
| { role: "system"; content: string }
| { role: "user"; content: string }
| { role: "assistant"; content: string; toolCalls?: ToolCall[] }
| { role: "tool"; toolResult: ToolResult };
这样下一轮模型能看到:
工具执行成功还是失败
错误码是什么
结果有没有截断
是否可重试
很多'模型不聪明'的问题,其实是 observation 写得太烂。
你只告诉它'failed',它当然只能猜。
第八步:保存 Trace 和 Checkpoint
每个 step 结束后,至少要保存两类东西:
trace:用于排障
checkpoint:用于恢复
Trace 记录:
context.build
model.complete
tool.plan
permission.review
tool.execute
checkpoint.save
Checkpoint 记录:
run state
messages
steps
tool ledger
permission decisions
文件变化
budget
没有 trace,失败后不知道哪里错。
没有 checkpoint,中断后只能从头来。
对有副作用的 Agent 来说,从头来很危险。
第九步:判断是否继续
Agent Loop 的停止条件不只有 final。
至少要考虑:
final
max_steps
budget_exhausted
permission_denied
repeated_error
repeated_tool_call
user_cancelled
paused_for_resume
停止原因要进入最终输出。
不要只说:
任务失败。
更好的表达是:
我停止在第 6 步,因为连续三次读取 src/foo.ts 都返回 NOT_FOUND。
建议先确认文件路径,或让我重新列出 src 目录。
用户不需要知道所有内部结构。
但用户需要知道系统为什么停。
一段真实 Runtime 顺序
本书 demo 里的主循环顺序是:
context -> model -> validate -> permission -> execute -> observe -> checkpoint
这条顺序我建议背下来。
不是为了考试。
是因为线上事故经常发生在顺序乱了。
先 execute 后 permission:权限系统变成日志。
先 observe 后 validate:坏工具调用污染历史。
execute 后不 checkpoint:副作用状态丢失。
final 不写 trace:用户只看到结论,看不到证据。
Runtime 的价值,不是把 while loop 写得更花。
是让每一步都有边界、证据和后路。
试读小结
Agent Loop 的完整生命周期可以压成一句话:
模型提出下一步,Runtime 决定这一步能不能被安全执行,并把结果变成下一步的事实。
这就是 Agent Runtime 和普通 LLM App 的分界。
普通 LLM App 主要管理文本。
Agent Runtime 管理行动。


