手写一个 100 行 Agent Loop
写 Agent Loop,最容易犯的错,是一上来就接真实模型。
你以为自己在调 runtime,其实你在同时调三件事:
模型行为是否稳定
工具 schema 是否写清楚
runtime 状态转移是否正确
这三件事混在一起,调试会很痛苦。模型没调用工具,你不知道是 prompt 不行,schema 不行,还是 loop 没把工具暴露出去。工具参数错了,你不知道是模型填错,还是本地校验太松。最终回答胡说,你也不知道它到底有没有看到工具结果。
所以我建议第一版 Agent Loop 不接真实模型。
先用一个 fake model。
没那么酷,但很稳。
100 行能写什么,不能写什么
先把话说清楚:100 行写不出生产级 Agent Runtime。
生产级系统还要有权限、trace、checkpoint、context budget、MCP、eval、sandbox、cost control。那些不是 100 行能糊出来的。
但 100 行可以写出一个干净骨架:
- Runtime 有自己的消息类型。
- Model 只返回 final 或 tool calls。
- ToolRegistry 负责找工具和校验工具。
- ToolResult 是结构化的。
- Loop 每一步都留下 step。
- 达到 final 或 maxSteps 时能停止。
这就够了。
一个好骨架,后面能长出工程能力。一个坏骨架,后面所有东西都是补丁。
从类型开始,不要从 SDK 开始
ReAct 论文讲的是 reasoning 和 acting 交替。OpenAI 的 function calling 文档也把工具调用描述成一个多步过程:模型提出 tool call,你执行工具,再把工具结果交回模型。LangChain JS 文档里也说,agent 会在 loop 中调用工具直到任务完成。LangGraph 则把状态、节点和边显式化,让 agent 流程更容易控制。
这些说法方向一致:Agent Loop 的关键不是'调用一次模型',而是'模型和外部世界之间有受控 roundtrip'。
所以第一步不要直接拿 provider SDK 的消息结构当内部类型。
先定义自己的中间表示:
type ToolCall = {
id: string;
name: string;
input: unknown;
};
type ModelResponse =
| { kind: "final"; text: string }
| { kind: "tool_calls"; calls: ToolCall[] };
type ToolResult = {
callId: string;
toolName: string;
status: "ok" | "error";
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 的边界。
模型可以换。工具可以换。Provider 字段名可以换。Runtime 自己的语言不要跟着飘。
ToolRegistry 不只是一个 Map
最小版本也别只写:
const tools = new Map<string, Function>();
至少给工具一个定义:
type ToolDefinition = {
name: string;
description: string;
validate(input: unknown): string[];
execute(input: unknown): Promise<ToolResult>;
};
class ToolRegistry {
private tools = new Map<string, ToolDefinition>();
register(tool: ToolDefinition) {
if (this.tools.has(tool.name)) {
throw new Error(`Tool already registered: ${tool.name}`);
}
this.tools.set(tool.name, tool);
}
list() {
return [...this.tools.values()].sort((a, b) =>
a.name.localeCompare(b.name)
);
}
plan(call: ToolCall) {
const tool = this.tools.get(call.name);
if (!tool) return { call, errors: ["UNKNOWN_TOOL"] };
const errors = tool.validate(call.input);
return { call, tool, errors };
}
}
这段代码还很简陋,但已经保住了三件事:
工具不存在,不能直接执行。
参数不合法,不能直接执行。
工具列表有稳定顺序,方便后面 prompt cache 和 trace。
很多事故就发生在这一步被省掉。
先写 fake model
fake model 的作用,是让 loop 可复现。
比如用户说'看看这个 workspace',fake model 第一步固定调用 list_files 和 read_file,第二步根据工具结果返回 final。
class FakeModel {
async complete(messages: RuntimeMessage[]): Promise<ModelResponse> {
const hasToolResult = messages.some((m) => m.role === "tool");
if (!hasToolResult) {
return {
kind: "tool_calls",
calls: [
{
id: "call_1",
name: "list_files",
input: { path: "." },
},
{
id: "call_2",
name: "read_file",
input: { path: "README.md" },
},
],
};
}
return {
kind: "final",
text: "已读取目录和 README,可以继续分析项目入口。",
};
}
}
这东西一点都不智能。
正因为不智能,它适合测试 runtime。你能稳定断言:第一步应该调用两个工具,第二步应该 final。等这个闭环跑通,再接真实模型。
100 行 Loop 骨架
下面是一份教学版骨架。为了能放进文章里,我省掉了文件系统实现、schema 库、trace writer 和真实 provider adapter,只保留主线。
type StopReason = "final" | "max_steps";
type RunState = {
userGoal: string;
messages: RuntimeMessage[];
steps: {
index: number;
modelResponse?: ModelResponse;
toolResults: ToolResult[];
stopReason?: StopReason;
}[];
finalAnswer?: string;
};
async function runAgent(input: {
userGoal: string;
model: { complete(messages: RuntimeMessage[]): Promise<ModelResponse> };
tools: ToolRegistry;
maxSteps: number;
}) {
const state: RunState = {
userGoal: input.userGoal,
messages: [{ role: "user", content: input.userGoal }],
steps: [],
};
for (let i = 1; i <= input.maxSteps; i++) {
const response = await input.model.complete(state.messages);
const step = {
index: i,
modelResponse: response,
toolResults: [] as ToolResult[],
stopReason: undefined as StopReason | undefined,
};
if (response.kind === "final") {
state.finalAnswer = response.text;
state.messages.push({ role: "assistant", content: response.text });
step.stopReason = "final";
state.steps.push(step);
return state;
}
state.messages.push({
role: "assistant",
content: "",
toolCalls: response.calls,
});
for (const call of response.calls) {
const plan = input.tools.plan(call);
if (!("tool" in plan) || plan.errors.length > 0) {
const result: ToolResult = {
callId: call.id,
toolName: call.name,
status: "error",
errorCode: plan.errors[0] ?? "UNKNOWN_TOOL",
content: `工具调用失败:${plan.errors.join(", ")}`,
metadata: {
durationMs: 0,
outputChars: 0,
truncated: false,
retryable: true,
},
};
state.messages.push({ role: "tool", toolResult: result });
step.toolResults.push(result);
continue;
}
const startedAt = Date.now();
const result = await plan.tool.execute(call.input);
result.callId = call.id;
result.toolName = call.name;
result.metadata.durationMs ||= Date.now() - startedAt;
state.messages.push({ role: "tool", toolResult: result });
step.toolResults.push(result);
}
state.steps.push(step);
}
state.steps.push({
index: input.maxSteps + 1,
toolResults: [],
stopReason: "max_steps",
});
return state;
}
严格数行,这段会因为格式化略微超过 100 行。别在这里较真。真正重要的是,它已经有了 runtime 的基本骨架。
这个骨架比 while loop 多了什么
最粗糙的 loop 通常是这样:
while (true) {
const response = await model(messages);
const results = await callTools(response.toolCalls);
messages.push(response, ...results);
}
上面那份骨架多了几个东西。
第一,ModelResponse 有明确分支。模型要么 final,要么 tool calls。Runtime 不靠字符串猜。
第二,工具调用先经过 ToolRegistry.plan()。未知工具和非法参数会变成 observation,而不是让执行器崩。
第三,ToolResult 结构化。模型看到 content,工程系统看到 errorCode、duration、truncated、retryable。
第四,step 被保存下来。后面接 trace、eval、resume 都有入口。
第五,停止原因存在。哪怕现在只有 final 和 max_steps,后面也能扩展 budget_exhausted、permission_denied、repeated_error。
这几个字段没有演示效果,但它们决定后面能不能工程化。
最小工具怎么写
第一版只写两个工具就够:
const listFiles: ToolDefinition = {
name: "list_files",
description: "List files under a workspace path.",
validate(input) {
if (!input || typeof input !== "object") return ["INPUT_NOT_OBJECT"];
if (typeof (input as any).path !== "string") return ["PATH_REQUIRED"];
return [];
},
async execute(input) {
const path = (input as any).path;
const content = `模拟目录 ${path}:\nREADME.md\nsrc/index.ts`;
return {
callId: "",
toolName: "list_files",
status: "ok",
content,
metadata: {
durationMs: 0,
outputChars: content.length,
truncated: false,
retryable: false,
},
};
},
};
真实实现当然要检查 workspace、隐藏 .git、限制输出大小。教学版先把接口跑通。
第二个工具可以是 read_file。同样先用模拟内容:
const readFile: ToolDefinition = {
name: "read_file",
description: "Read a small text file from workspace.",
validate(input) {
if (!input || typeof input !== "object") return ["INPUT_NOT_OBJECT"];
if (typeof (input as any).path !== "string") return ["PATH_REQUIRED"];
return [];
},
async execute(input) {
const path = (input as any).path;
const content = `文件 ${path}:\n这是一个 Agent Runtime demo。`;
return {
callId: "",
toolName: "read_file",
status: "ok",
content,
metadata: {
durationMs: 0,
outputChars: content.length,
truncated: false,
retryable: false,
},
};
},
};
我知道这看起来有点玩具。
但它能测试一件很关键的事:模型提出工具调用,runtime 校验,工具返回 observation,模型再基于 observation final。
这条链先站住。
先跑通四个用例
不要一写完就接真实模型。
先用 fake model 跑四个 case:
case 1:模型调用 list_files,工具成功,下一轮 final。
case 2:模型调用 unknown_tool,runtime 返回 UNKNOWN_TOOL。
case 3:模型传错参数,runtime 返回 PATH_REQUIRED。
case 4:模型一直不 final,runtime 因 max_steps 停止。
这四个 case 能暴露大部分初学者会漏的东西。
尤其是 unknown tool 和 invalid args。很多 demo 遇到这两个问题会直接 throw,然后你以为是'模型不稳定'。其实是 runtime 没把错误变成模型能理解的 observation。
和书里的 demo 怎么对应
本书仓库里的配套 demo 更完整一点。
demo/src/types.ts 定义了 Runtime 自己的语言:RuntimeIds、ToolCall、ToolResult、RuntimeMessage、RunStep、RuntimeEvent。
demo/src/runtime.ts 里的主循环顺序是:
context
↓
model
↓
validate
↓
permission
↓
execute
↓
observe
↓
checkpoint
demo/src/tools/tool-registry.ts 说明 ToolRegistry 不只是 Map,它还承担本地校验和治理信息。
demo/src/providers/scripted-provider.ts 则是 fake model。它让 eval 可以稳定断言 Agent 是否调用了正确工具、是否被审批、是否完成写入和测试。
换句话说,文章里的 100 行不是最终代码。它是书里完整 demo 的缩小版。
接真实模型前,再问自己几个问题
在接 OpenAI、Anthropic 或其他模型前,先问:
Runtime 是否有自己的 ModelResponse 类型?
工具调用是否先经过 ToolRegistry?
非法参数是否变成 ToolResult,而不是 throw 出 loop?
ToolResult 里有没有 errorCode、truncated、retryable?
每个 step 有没有记录?
maxSteps 是否生效?
final answer 是否只在模型明确 final 时返回?
fake model 的四个 case 是否都跑过?
这些都过了,再接真实模型。
否则真实模型只会把 runtime 自己的问题放大。
最后
手写 Agent Loop 的价值,不是证明'框架都没用'。
框架当然有用。LangGraph、LangChain、OpenAI Agents SDK 都在解决真实问题。
手写一遍的价值在于:你会知道框架帮你管了什么,也知道它没管什么。
一个 Agent Loop 最小也要回答:
模型说了什么?
工具调了什么?
参数是否合法?
结果怎么写回?
什么时候继续?
什么时候停止?
出错后模型看到什么?
工程师留下什么证据?
这几个问题想清楚,你就不再只是把 tool calling 示例复制进项目。
你开始写 Runtime 了。
参考资料
- ReAct paper: Synergizing Reasoning and Acting in Language Models
- OpenAI API Docs: Function calling
- LangChain JS Docs: Agents
- LangChain Python Docs: Agents
- LangGraph Docs: Graphs


