跳到主要内容《Agent Runtime 工程化》第三章 最小 Agent Loop | 极客日志编程语言Agent Runtime工程化陈堂会
《Agent Runtime 工程化》第三章 最小 Agent Loop
现在可以动手了。把模型、工具和观察结果接成一个受控循环,这是 Agent Runtime 从“调用一次模型”走向“能执行任务”的第一步。 最小 Agent Loop 不追求复杂。它的目标很朴素:模型能根据当前上下文提出工具调用,runtime 能校验并执行工具,把观察结果写回消息历史,再决定继续还是停止。只要这个闭环清楚,后面的工具治理、上下文预算、权限审批
站点编辑3 浏览

书名:《Agent Runtime 工程化:从工具调用循环到可恢复执行系统》
作者:陈堂会
平台:极客日志(https://zeeklog.com)
X:@Megick_com
联系邮箱:[email protected]
章节:第三章 最小 Agent Loop
第三章 最小 Agent Loop
现在可以动手了。把模型、工具和观察结果接成一个受控循环,这是 Agent Runtime 从'调用一次模型'走向'能执行任务'的第一步。
最小 Agent Loop 不追求复杂。它的目标很朴素:模型能根据当前上下文提出工具调用,runtime 能校验并执行工具,把观察结果写回消息历史,再决定继续还是停止。只要这个闭环清楚,后面的工具治理、上下文预算、权限审批、checkpoint 和 trace 才有地方落脚。
反过来,如果这一章写得含糊,后面所有工程化能力都会变成补丁。很多看起来'模型不聪明'的问题,其实是 runtime 没把工具结果说清楚,没把失败变成可理解的 observation,或者没记录每一步为什么发生。

图 3-1:最小 Agent Loop 的一次 roundtrip。Runtime 不是被模型牵着走,它在上下文装载、工具校验、权限判断、执行、观察写回和停止条件之间维持控制权。
3.1 最小循环先定义契约
写 Agent Loop 的第一件事,不是选 SDK,而是定义 runtime 自己的中间表示。SDK 会变,模型供应商会变,tool call 的字段名也会变。runtime 要有自己的 ModelResponse、ToolCall、ToolResult 和 RuntimeMessage,这样系统不会被某一个接口格式锁死。
一个足够小的类型集合可以从这里开始:
type ModelResponse =
| { kind: "final"; text: string; usage?: TokenUsage }
| { kind: "tool_calls"; calls: []; ?: };
= {
: ;
: ;
: ;
};
= {
: ;
: ;
: | | | | ;
: ;
?: ;
: {
: ;
: ;
: ;
: ;
};
};
=
| { : ; : }
| { : ; : ; ?: [] }
| { : ; : };
ToolCall
usage
TokenUsage
type
ToolCall
id
string
name
string
input
unknown
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
type
RuntimeMessage
role
"user"
content
string
role
"assistant"
content
string
toolCalls
ToolCall
role
"tool"
toolResult
ToolResult
这些类型看上去普通,却决定了 runtime 的气质。ToolResult.status 让失败有语义,metadata.truncated 让上下文构建器知道输出是否被裁剪,retryable 让模型不必从错误文案里猜能不能再试一次。越早把这些字段写清楚,后面越少靠日志猜谜。
| 接口 | 责任 | 不该负责 |
|---|
ModelProvider | 把 runtime messages 转成模型请求,再把模型响应转成 ModelResponse | 不直接执行工具 |
MessageStore | 追加用户消息、模型消息、工具结果,提供当前 run 的历史 | 不做预算裁剪 |
ToolRegistry | 注册工具、暴露 schema、校验输入、找到 executor | 不替用户批准高风险动作 |
LoopController | 调度 step、处理停止条件、发出 runtime event | 不理解具体业务工具内部实现 |
边界清楚以后,最小 Agent Loop 就不是一段 while 拼接代码,而是一个可以生长的 runtime 骨架。
3.2 一次 step 应该留下什么
Agent Runtime 的基本单位不是一整段对话,而是 step。一次 step 包含:上下文快照、模型响应、工具计划、执行结果、停止判断。后面做 trace、eval、resume 时,都会回到 step 这个粒度。
type RunStep = {
index: number;
startedAt: string;
contextDigest: {
messageCount: number;
estimatedTokens: number;
includedFiles: string[];
};
modelResponse?: ModelResponse;
toolResults: ToolResult[];
stopReason?: StopReason;
endedAt?: string;
};
type StopReason =
| "final"
| "no_tool_call"
| "max_steps"
| "repeated_error"
| "budget_exhausted"
| "user_cancelled"
| "permission_denied";
contextDigest 不需要保存完整上下文。完整上下文可能太大,也可能包含敏感信息。它要保存的是排障线索:本轮大概装了多少消息、估算 token 多少、哪些文件片段进入了模型视野。等第五章做 Context Builder 时,这里会升级为更完整的 debug report。
很多入门实现只保存最终答案,这不够。模型调错工具、工具输出被截断、重复读取同一文件、权限被拒绝后仍然尝试高风险路径,这些问题都发生在 step 里面。如果 step 没有证据,调试只能靠重跑。
3.3 tool schema 有两个读者
工具 schema 不是给模型看的摆设。它有两个读者:模型用它决定'该怎么填参数',runtime 用它判断'这个调用能不能执行'。前者服务规划,后者服务安全和稳定。
import { z } from "zod";
const ReadFileInput = z.object({
path: z.string().min(1),
startLine: z.number().int().positive().optional(),
endLine: z.number().int().positive().optional(),
});
type ReadFileInput = z.infer<typeof ReadFileInput>;
这段 schema 只能证明参数形状大致正确。它还不能证明路径在 workspace 内,也不能证明文件大小适合放进上下文。runtime 还需要策略校验:
function validateReadFilePolicy(input: ReadFileInput, workspaceRoot: string) {
const absolutePath = resolveInsideWorkspace(workspaceRoot, input.path);
if (!absolutePath) {
return { ok: false as const, reason: "PATH_OUTSIDE_WORKSPACE" };
}
if (input.endLine && input.startLine && input.endLine < input.startLine) {
return { ok: false as const, reason: "INVALID_LINE_RANGE" };
}
return { ok: true as const, absolutePath };
}
模型返回非法参数时,不要让程序抛异常后结束。更好的做法是把错误转成工具观察,写回消息历史:
function invalidArgs(call: ToolCall, reason: string): ToolResult {
return {
callId: call.id,
toolName: call.name,
status: "error",
errorCode: "INVALID_ARGUMENTS",
content: `工具参数不合法:${reason}。请修正参数后重新调用。`,
metadata: {
durationMs: 0,
outputChars: 0,
truncated: false,
retryable: true,
},
};
}
这条 observation 会给模型一个重新规划的机会。注意这里的重点:参数错误不是 runtime 崩溃,它只是任务过程中的一次观察。
3.4 roundtrip 的六个动作
一次 tool call roundtrip 至少包含六个动作:
- Runtime 调用 Context Builder,装载当前消息、工具描述和运行状态。
- Model Provider 请求模型,得到 final answer 或 tool calls。
- Tool Registry 校验工具名和输入参数。
- Permission Gate 判断是否允许执行,最小阶段可以只有只读策略。
- Tool Executor 执行工具,返回结构化结果。
- Message Store 写入 tool result,Loop Controller 判断继续或停止。
这六步少一步都容易出问题。跳过工具名校验,模型幻觉出来的工具会进入执行器。跳过权限判断,后面加入写入工具时会失控。跳过结构化结果,模型只看到一段错误文本,很难知道下一步该重试、搜索还是停止。
一个最小 controller 可以写成异步生成器,让 UI、CLI 和 trace 同时消费 runtime event:
type RuntimeEvent =
| { type: "run_started"; runId: string }
| { type: "step_started"; step: number }
| { type: "model_delta"; text: string }
| { type: "tool_call"; call: ToolCall }
| { type: "tool_result"; result: ToolResult }
| { type: "run_finished"; reason: StopReason; answer?: string };
async function* runAgent(input: RunInput): AsyncGenerator<RuntimeEvent> {
const state = createRunState(input);
yield { type: "run_started", runId: state.runId };
while (true) {
const decision = shouldStopBeforeModel(state);
if (decision.stop) {
yield { type: "run_finished", reason: decision.reason };
return;
}
const stepIndex = state.steps.length + 1;
yield { type: "step_started", step: stepIndex };
const context = await input.contextBuilder.build(state);
const response = await input.model.complete(context.messages, {
tools: input.tools.toModelSchemas(),
signal: input.signal,
});
if (response.kind === "final") {
state.finalAnswer = response.text;
yield { type: "run_finished", reason: "final", answer: response.text };
return;
}
for (const call of response.calls) {
yield { type: "tool_call", call };
const result = await executeToolCall(call, input.tools, state);
state.messages.push({ role: "tool", toolResult: result });
yield { type: "tool_result", result };
}
state.steps.push({
index: stepIndex,
startedAt: new Date().toISOString(),
contextDigest: context.digest,
modelResponse: response,
toolResults: state.lastToolResults,
});
}
}
这里省略了一些细节,例如流式模型输出、并行工具和 trace 落盘,但主线已经完整:runtime 掌握循环,模型只在每一步给出建议或答案。
3.5 三个最小工具怎么选
本阶段建议只实现三个工具:list_files、read_file、shell_readonly。
list_files 负责让模型了解目录结构。它必须限制在 workspace 内,默认隐藏 .git、node_modules、构建产物和大体积目录。它返回的不是完整文件系统,而是当前任务需要的入口线索。
read_file 负责提供事实片段。它要限制最大字节数和最大行数,超出时返回截断信息,而不是默默截断。模型需要知道'我看到的是文件的一部分'。
shell_readonly 负责运行只读命令。不要一开始开放任意 shell。白名单可以从 pwd、ls、find、rg、cat、sed、wc 这类命令开始,并且禁止重定向写入、管道里的危险命令和网络访问。
const READONLY_COMMANDS = new Set(["pwd", "ls", "find", "rg", "cat", "sed", "wc"]);
function classifyReadonlyCommand(command: string) {
const first = command.trim().split(/\s+/)[0];
if (!READONLY_COMMANDS.has(first)) return "DENY_UNKNOWN_COMMAND";
if (/[>|;&]/.test(command)) return "DENY_SHELL_OPERATOR";
if (/\b(rm|mv|cp|chmod|chown|curl|wget|npm|pnpm|yarn)\b/.test(command)) {
return "DENY_RISKY_TOKEN";
}
return "ALLOW";
}
这个分类器并不完美,但它表达了入门阶段的工程态度:先让 Agent 可靠地读,再让它安全地写。写入工具会引入 diff preview、审批、checkpoint 和 rollback,应该放到第四章、第七章和第八章再处理。
3.6 工具结果要给模型,也要给工程系统
工具结果有两种用途。模型需要一段干净、短小、足够继续推理的 observation;工程系统需要完整的排障字段。不要把二者混在一段字符串里。
以 read_file 成功为例,给模型看的 content 可以是文件片段:
文件 src/runtime/loop.ts 第 1-80 行:
...
而给 trace 的 metadata 应该包含更多工程信息:
{
"durationMs": 12,
"outputChars": 6120,
"truncated": true,
"retryable": false,
"path": "src/runtime/loop.ts",
"lineRange": [1, 80],
"fileSizeBytes": 48192
}
第五章会讲如何把大结果降级进上下文。这里先记住一条原则:模型上下文要干净,trace 证据要完整。把所有东西都塞给模型,会增加成本,也会让模型被日志噪声带偏;只给模型一个'失败了',工程师又没法排障。
3.7 停止条件不是硬刹车那么简单
没有最大步数的 Agent Loop 是事故入口。模型可能重复读同一个文件,也可能在工具失败后不断用同一组参数重试。最小 runtime 必须有 maxSteps,但只有 maxSteps 不够。
- 模型返回 final answer;
- 模型没有工具调用;
- 达到最大 step;
- 连续出现相同错误;
- 连续工具调用没有带来新信息;
- token 或成本预算耗尽;
- 用户取消;
- 权限被拒绝且没有低风险替代路径。
function shouldStopAfterStep(state: RunState): StopDecision {
if (state.finalAnswer) return { stop: true, reason: "final" };
if (state.steps.length >= state.maxSteps) {
return { stop: true, reason: "max_steps" };
}
if (state.sameErrorCount >= 3) {
return { stop: true, reason: "repeated_error" };
}
if (state.tokenBudget.remaining <= 0) {
return { stop: true, reason: "budget_exhausted" };
}
if (state.signal.aborted) {
return { stop: true, reason: "user_cancelled" };
}
return { stop: false };
}
停止原因要进入最终回答。用户不一定关心内部 step,但会关心为什么没有继续。好的表达是:'我停止在第 8 步,因为连续三次读取同一路径都返回 NOT_FOUND;建议先确认文件名。'不要只说'失败了'。
3.8 流式输出与事件设计
CLI Agent、IDE Agent 和 Web Agent 都需要流式反馈,但反馈的粒度不同。用户想看到模型正在思考任务进度,工程系统想看到每个事件的时间戳和关联 ID。最小 runtime 不必一步到位接 OpenTelemetry,但应该先有事件模型。
type RuntimeIds = {
sessionId: string;
runId: string;
stepId: string;
toolCallId?: string;
};
事件命名不要太随意。tool_call_started、tool_call_finished、tool_call_failed 比 log 更容易被 UI、trace 和 eval 复用。事件里不要直接塞完整文件内容,长文本放到 artifact 或 trace store,再用引用连接。
{
"runId": "run_20260814_001",
"userGoal": "阅读 package.json 和 src 目录,说明启动方式",
"maxSteps": 10,
"steps": [
{
"index": 1,
"model": "provider-model-name",
"toolCalls": [
{ "id": "call_1", "name": "read_file", "input": { "path": "package.json" } }
],
"toolResults": [
{ "callId": "call_1", "status": "ok", "truncated": false }
]
}
],
"finished": { "reason": "final" }
}
这份 trace 不需要华丽。它要能回答三个问题:模型为什么这么做,工具实际发生了什么,runtime 为什么停止。
3.9 常见失败与修正
第一类失败是非法参数。模型传了不存在的路径、错误类型、缺字段。处理方式是 schema 校验加 observation 反馈,允许模型修正一次或几次。
第二类失败是工具不存在。模型调用了没注册的工具。runtime 应返回 UNKNOWN_TOOL,并给出简短可用工具列表。不要把完整工具手册塞回去,那会浪费上下文。
第三类失败是输出过大。处理方式是截断、摘要或保存为 artifact,再把引用写入 observation。模型必须知道输出被截断。
第四类失败是循环不收敛。处理方式是 maxSteps、重复错误检测、相同工具相同参数去重。可以在 trace 中记录 fingerprint:
function toolCallFingerprint(call: ToolCall) {
return `${call.name}:${stableJsonStringify(call.input)}`;
}
第五类失败是最终回答没有证据。处理方式不是继续加 prompt,而是让 runtime 明确要求模型基于 observation 回答,并在最终回答前检查是否发生过相关工具观察。对 coding agent 来说,'没有读取文件却总结文件内容'应当被视为质量缺陷。
3.10 本章的实现路径
第一次提交:建好 ModelProvider、ToolRegistry、MessageStore 和 LoopController 的类型,不接真实模型,用 fake model 返回固定 tool call。
第二次提交:实现 list_files 和 read_file,完成 schema 校验、workspace 路径限制、截断策略和结构化 ToolResult。
第三次提交:加入 shell_readonly,用白名单和超时保护控制命令执行,支持 AbortSignal。
第四次提交:把 controller 改成异步事件流,落一份 JSON trace,补上停止条件和重复错误检测。
mini-agent/
├── src/
│ ├── index.ts
│ ├── runtime/
│ │ ├── loop.ts
│ │ ├── messages.ts
│ │ ├── events.ts
│ │ ├── trace.ts
│ │ └── stop.ts
│ ├── model/
│ │ ├── provider.ts
│ │ └── fake-provider.ts
│ └── tools/
│ ├── registry.ts
│ ├── list-files.ts
│ ├── read-file.ts
│ └── shell-readonly.ts
└── package.json
先用 fake model 跑通,比一开始接真实模型更稳。你要先证明 runtime 自己的状态转移没问题,再让真实模型带来不确定性。
3.11 验收标准
完成本章后,你的 mini-agent 应能根据用户请求读取项目文件、总结事实、在缺少信息时继续调用工具,并在达到停止条件时说明原因。
| 用例 | 期望结果 |
|---|
读取 package.json 并说明启动方式 | Agent 调用 read_file,回答引用文件事实 |
| 请求读取不存在的文件 | 工具返回 NOT_FOUND,模型改用 list_files 或停止说明 |
| 模型传入非法参数 | runtime 返回 INVALID_ARGUMENTS observation,不崩溃 |
| fake model 重复调用同一错误工具 | runtime 在阈值后以 repeated_error 停止 |
到这里,你已经有了 Agent Runtime 的胚胎。它还不能安全写文件,也没有长期记忆和恢复能力,但它的循环、状态和停止语义已经站住了。后面的章节会把这个胚胎一点点加固成可进入真实项目的运行系统。
系列文章目录
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online