这是「从零搭建 Agent」系列的第二篇。上一篇我们先搭了理论骨架:Agent Loop 是心脏,Harness 是围绕这个循环做上下文工程和注意力管理的系统。从这一篇开始,我们把理论实践到代码里:先不做复杂的 Harness,只实现一个最小但能跑起来的 Agent Loop。
# 这一节要实现什么?
第一篇里我们说,一个最小 Agent Loop 可以抽象成:
感知(Observe)→ 思考(Think)→ 行动(Act)
这句话听起来很像概念,但写成代码其实就是一个循环:
- 把用户输入和历史消息交给模型。
- 模型要么直接回答,要么请求调用工具。
- 如果模型请求工具,Agent 执行工具,把结果写回历史。
- 模型在下一轮看到工具结果,再继续回答或继续调用工具。
- 如果模型不再调用工具且目标已完成,循环结束。
这一节的目标是做一个最小的 Agent Loop,它只需要具备三个核心能力:
| 模块 | 作用 | 这一版做到什么程度 |
|---|---|---|
| Agent Loop | 驱动模型和工具之间的循环 | 支持多轮工具调用、最终回答、最大轮次保护 |
| LLM Client | 封装模型 API | 支持 OpenAI Responses API、非流式与流式接口 |
| Tool System | 让模型操作外部世界 | 支持工具注册、schema 暴露、参数校验、错误回填 |
这三个模块跑通之后,Agent 就不再只是一个 “问答包装器”。它可以根据任务主动决定是否调用工具,并基于工具返回结果继续推理。
比如用户问:
Calculate (123 + 456) * 789, then tell me the result. |
普通 LLM Client 会把问题丢给模型,然后等模型直接生成答案。Agent Loop 则多了一个动作空间:
User question | |
-> model decides to call calculator | |
-> agent executes calculator | |
-> observation: 456831 | |
-> model sees observation | |
-> final answer |
这个 “工具结果重新进入模型上下文” 的动作,就是 Agent 与普通聊天机器人最关键的分界线。
# 同类 Agent 是怎么做的?
在写我们自己的实现前,我看了两个已经存在的 agent 源码:Codex 和 Pi。它们代表了两个很好的参照系:
- Codex:生产级实现,turn loop、工具 runtime、上下文压缩、hooks、权限、安全和事件系统都很完整。
- Pi:轻量级 TypeScript 实现,Agent Loop 非常显式,结构更简单。
# Codex:生产级 turn loop
Codex 的 Agent Loop 分散在 turn、sampling request、Responses stream 和 tool runtime 之间,核心逻辑在 codex-rs/core/src/session/turn.rs 。源码注释是这样描述这个 turn loop:
/// Takes initial turn input and runs a loop where, at each sampling request, | |
/// the model replies with either: | |
/// | |
/// - requested function calls | |
/// - an assistant message | |
/// | |
/// While it is possible for the model to return multiple of these items in a | |
/// single sampling request, in practice, we generally one item per sampling request: | |
/// | |
/// - If the model requests a function call, we execute it and send the output | |
/// back to the model in the next sampling request. | |
/// - If the model sends only an assistant message, we record it in the | |
/// conversation history and consider the turn complete. | |
/// |
在每一次 sampling request 中,模型要么返回 function call,要么返回 assistant message。
如果返回 function call,就执行工具,并把工具输出送回下一次 sampling request。
如果只返回 assistant message,就记录历史并认为 turn 完成。
抽象成伪代码大概是:
while (true) { | |
const prompt = buildPrompt(history, visibleTools); | |
const output = await model.stream(prompt); | |
if (output.hasToolCall) { | |
history.push(output.toolCall); | |
const result = await toolRuntime.execute(output.toolCall); | |
history.push(result); | |
continue; | |
} | |
history.push(output.assistantMessage); | |
return output.assistantMessage; | |
} |
在 Codex 的实现中,可以看到三个值得学习的点:
第一,工具结果必须回填给模型。工具调用和调用结果是 conversation history 的一部分。模型下一轮必须看到 observation,才能继续推理。
第二,工具错误也是上下文。在 codex-rs/core/src/tools/parallel.rs 里,非 fatal 的工具错误会被转换成失败的 function call output,而不是直接让整个 turn 崩掉。也就是说, command failed 、 tool not found 、 permission denied 这类信息都应该成为模型可见的 observation。
第三,并行执行和顺序回填要分开。Codex 的工具 runtime 会根据工具是否支持 parallel 选择并行或串行,但输出仍然以稳定方式写回历史,避免模型看到的上下文顺序漂移。
# Pi:显式 Agent Loop
Pi 的核心循环在 packages/agent/src/agent-loop.ts ,它的实现形态更适合我们这一节学习。
Pi 的 runLoop 大致是:
context messages | |
-> streamAssistantResponse | |
-> assistant message | |
-> extract tool calls | |
-> execute tools | |
-> append toolResult messages | |
-> next turn |
它做了两个很实用的设计:
第一,工具执行策略可配置。Pi 会检查全局 toolExecution 和每个工具自己的 executionMode 。如果任一工具要求顺序执行,就走 sequential;否则可以 parallel。
第二,事件流是一等接口。Pi 会发出 agent_start 、 turn_start 、 message_start 、 tool_execution_start 、 tool_execution_end 、 agent_end 这类事件。这样 CLI、TUI、Web UI、日志系统都可以订阅同一条 agent event stream。
所以我们这一版的实现策略很明确:
- 学 Codex 的调用逻辑:工具结果回填、工具错误回填、并行执行但顺序写回。
- 学 Pi 的形态:显式 TypeScript loop、清晰的 LLM 边界、事件驱动。
- 暂时不做复杂 Harness。
# 我们的实现结构
当前项目的整体逻辑是:
User input | |
-> Agent.run() | |
-> append user message | |
-> LlmClient.complete() or StreamingLlmClient.stream() | |
-> assistant message | |
-> if no tool calls: return final output | |
-> if tool calls: | |
ToolExecutor.execute(...) | |
append tool result messages | |
continue next turn |
Agent 对象不关心 OpenAI Responses API 的具体 payload,也不关心工具函数内部怎么执行。它只负责整体循环。
模型调用被压到 LlmClient :
export interface LlmClient { | |
complete(request: LlmRequest): Promise<AssistantMessage>; | |
} | |
export interface StreamingLlmClient extends LlmClient { | |
stream(request: LlmRequest): AsyncIterable<LlmStreamEvent>; | |
} |
工具调用被压到 ToolRegistry 和 ToolExecutor :
export type AgentTool = { | |
name: string; | |
description: string; | |
parameters: JsonSchema; | |
executionMode?: "sequential" | "parallel"; | |
execute(args: unknown, context: ToolExecutionContext): Promise<ToolResult> | ToolResult; | |
}; |
于是 Agent Loop 本身可以保持很小。
# Agent Loop 核心代码
当前最小循环在 src/agent/agent-loop.ts 的 runInternal 里。
精简后是这样:
private async runInternal(input: string, options: AgentRunOptions = {}): Promise<AgentRunResult> { | |
const maxTurns = options.maxTurns ?? this.maxTurns; | |
const userMessage: AgentMessage = { role: "user", content: input }; | |
this.messages.push(userMessage); | |
let lastAssistant: AssistantMessage | undefined; | |
for (let turn = 1; turn <= maxTurns; turn += 1) { | |
const assistant = await this.completeAssistant(turn, { | |
model: this.model, | |
systemPrompt: this.systemPrompt, | |
messages: [...this.messages], | |
tools: this.tools.toLlmToolSpecs(), | |
reasoning: options.reasoning ?? this.reasoning, | |
signal: options.signal | |
}); | |
lastAssistant = assistant; | |
this.messages.push(assistant); | |
const toolCalls = assistant.toolCalls ?? []; | |
if (toolCalls.length === 0) { | |
return this.buildResult(assistant.content, turn, "final"); | |
} | |
const toolResults = await this.executeToolCalls(turn, toolCalls, options.signal); | |
this.messages.push(...toolResults); | |
} | |
return this.buildResult(lastAssistant?.content ?? "", maxTurns, "max_turns"); | |
} |
这段代码就是 Observe / Think / Act(aka reAct) 的工程版本:
| 理论概念 | 代码对应 |
|---|---|
| Observe | messages 里已有的 user、assistant、tool result |
| Think | completeAssistant(...) 调用模型 |
| Act | executeToolCalls(...) 执行模型请求的工具 |
| Observe again | this.messages.push(...toolResults) 将工具结果写回历史 |
| Stop | assistant 没有 tool call,或达到 maxTurns |
注意这里的 messages: [...this.messages] ,每一轮模型看到的是当前完整 history。工具结果作为 role: "tool" 的消息存在,所以模型能基于上一次 action 的 observation 继续行动。
# LLM Client:隔离模型供应商
我们的 OpenAI 格式接口接入在 src/llm/openai-responses-client.ts 。它做两件事:
- 把内部消息结构转换成 Responses API 的
input。 - 把 Responses API 的输出转换回内部
AssistantMessage。
内部消息到 Responses input 的转换逻辑大概是:
if (message.role === "user") { | |
input.push({ | |
type: "message", | |
role: "user", | |
content: [{ type: "input_text", text: message.content }] | |
}); | |
} | |
if (message.role === "assistant") { | |
input.push({ | |
type: "message", | |
role: "assistant", | |
content: [{ type: "output_text", text: message.content }] | |
}); | |
for (const toolCall of message.toolCalls ?? []) { | |
input.push({ | |
type: "function_call", | |
call_id: toolCall.id, | |
name: toolCall.name, | |
arguments: JSON.stringify(toolCall.arguments ?? {}) | |
}); | |
} | |
} | |
if (message.role === "tool") { | |
input.push({ | |
type: "function_call_output", | |
call_id: message.toolCallId, | |
output: message.content | |
}); | |
} |
这个转换层很重要。Agent Loop 只需要认识 AgentMessage ,不用认识 Responses API、Chat Completions API 或 Anthropic API。以后要新增其他的 provider 支持,只需要实现新的 LlmClient ,不需要重写整个 Agent Loop。
当前 OpenAI Responses Client 同时支持了支持 streaming。它会把 SSE 事件归一成内部事件:
| Responses stream event | 内部事件 |
|---|---|
response.output_text.delta | text_delta |
response.reasoning_summary_text.delta | thinking_delta |
response.function_call_arguments.delta | tool_call_delta |
response.completed | done |
所以 Agent 可以一边接收文本增量,一边保留最终 AssistantMessage ,不需要把 streaming 和非 streaming 写成两套循环。
# Tool System:让错误也进入循环
工具注册表很简单:
export class ToolRegistry { | |
private readonly tools = new Map<string, AgentTool>(); | |
register(tool: AgentTool): void { | |
if (this.tools.has(tool.name)) { | |
throw new Error(`Tool already registered: ${tool.name}`); | |
} | |
this.tools.set(tool.name, tool); | |
} | |
get(name: string): AgentTool | undefined { | |
return this.tools.get(name); | |
} | |
toLlmToolSpecs(): LlmToolSpec[] { | |
return this.list().map((tool) => ({ | |
name: tool.name, | |
description: tool.description, | |
parameters: tool.parameters | |
})); | |
} | |
} |
执行器也保持了最小:
export class ToolExecutor { | |
constructor(private readonly registry: ToolRegistry) {} | |
async execute(toolCall: ToolCall, signal?: AbortSignal): Promise<ToolResultMessage> { | |
const tool = this.registry.get(toolCall.name); | |
if (!tool) { | |
return toToolResultMessage(toolCall, { | |
content: `Tool not found: ${toolCall.name}`, | |
isError: true | |
}); | |
} | |
try { | |
validateArguments(tool.parameters, toolCall.arguments); | |
const result = await tool.execute(toolCall.arguments, { | |
toolCallId: toolCall.id, | |
signal | |
}); | |
return toToolResultMessage(toolCall, result); | |
} catch (error) { | |
return toToolResultMessage(toolCall, { | |
content: error instanceof Error ? error.message : String(error), | |
isError: true | |
}); | |
} | |
} | |
} |
这里有一个小但关键的设计:工具不存在、参数错误、执行抛错,都不会直接中断 Agent。它们会变成一条 role: "tool" 且 isError: true 的消息,被写回 history。
这就是第一篇里说的原则:Failures as First-Class Citizens。
对模型来说,错误不是外部异常,而是一条 observation。模型可以基于它修正参数、换工具、或者告诉用户失败原因。
# 示例工具
当前内置了两个教学工具: calculator 和 get_weather 。
calculator 的定义是:
export const calculatorTool: AgentTool = { | |
name: "calculator", | |
description: "Evaluate a basic arithmetic expression.", | |
parameters: { | |
type: "object", | |
properties: { | |
expression: { | |
type: "string", | |
description: "Arithmetic expression using numbers, parentheses, +, -, *, /, %, and **." | |
} | |
}, | |
required: ["expression"], | |
additionalProperties: false | |
}, | |
execute(args) { | |
const expression = readStringProperty(args, "expression"); | |
if (!/^[\d\s+\-*/().%]+$/.test(expression)) { | |
throw new Error("Calculator only accepts numbers, whitespace, parentheses, and arithmetic operators."); | |
} | |
const value = Function(`"use strict"; return (${expression});`)() as unknown; | |
if (typeof value !== "number" || !Number.isFinite(value)) { | |
throw new Error("Expression did not produce a finite number."); | |
} | |
return { content: String(value) }; | |
} | |
}; |
这个工具写的很简单,主要是为了能用就行,重点是跑通工具调用链路:
tool schema -> model tool call -> tool execution -> tool result message -> next model request |
# 运行示例
这个简单的 demo 可以一行命令启动。下面这次测试使用的是兼容 OpenAI Responses API 的代理端点和 gpt-5.5 :
OPENAI_BASE_URL="https://xxx" \ | |
OPENAI_MODEL="gpt-5.5" \ | |
OPENAI_API_KEY="..." \ | |
AGENT_REASONING_EFFORT="xhigh" \ | |
npm run demo -- "Calculate (123 + 456) * 789123123. then reply who are you" |
实际输出如下:
[turn 1] | |
[thinking] | |
**Calculating arithmetic accurately** | |
I need to respond to the user by calculating the expression they provided, | |
which is (123 + 456) * 789,123,123. ... | |
[tool args] calculator {"expression":"(123 + 456) * 789123123"} | |
[tool] calculator {"expression":"(123 + 456) * 789123123"} | |
[observation] 456902288217 | |
[turn 2] | |
456902288217 | |
I’m an AI assistant. |
这个输出能清楚看到两轮循环:
第一轮,模型没有直接回答,而是先输出 reasoning summary,然后流式生成 calculator 的工具参数。Agent 执行工具后得到 observation: 456902288217 。
第二轮,模型看到工具 observation,再生成最终回答:先给出计算结果,然后回答 “I’m an AI assistant.”。
# 我们从现有项目学到了什么?
这一版虽然叫 “最小 Agent Loop”,但它不是最原始的 while loop。它在几个地方提前保留了扩展点。
# 1. 流式和非流式共用同一个 loop
if (!isStreamingLlmClient(this.llm)) { | |
return this.llm.complete(request); | |
} | |
for await (const event of this.llm.stream(request)) { | |
// thinking_delta / text_delta / tool_call_delta / done | |
} |
streaming 只是 LLM Client 的增强能力。Agent Loop 仍然只等待一个最终 AssistantMessage 来决定是否执行工具。
# 2. 工具可以并行执行,但历史顺序稳定
executeToolCalls 支持两种策略:
const mustRunSequentially = | |
this.toolExecution === "sequential" || | |
toolCalls.some((toolCall) => this.tools.get(toolCall.name)?.executionMode === "sequential"); | |
if (mustRunSequentially) { | |
// one by one | |
} | |
const results = await Promise.all( | |
toolCalls.map(async (toolCall) => this.executor.execute(toolCall, signal)) | |
); |
如果模型一次请求多个互不依赖的工具,未来可以并发执行。但 Promise.all 的结果顺序和输入数组一致,所以写回 history 的顺序仍然稳定。Codex 和 Pi 的实现中都有这一部分:性能可以并行,模型看到的上下文不能乱。
# 3. 工具错误不会打断循环
工具错误被转换为:
{ | |
role: "tool", | |
toolCallId: toolCall.id, | |
toolName: toolCall.name, | |
content: "...error message...", | |
isError: true | |
} |
这让模型有机会 “读到失败”,而不是让宿主程序直接抛异常结束。
在真实 Agent 中,这个细节非常重要。因为工具失败太常见了:文件不存在、命令退出码非零、网络请求 429、参数 schema 不匹配、权限不足。失败如果不进入上下文,模型就没有自我修正的机会。
# 4. 事件系统先行
当前 Agent 支持两种事件消费方式:
new Agent({ | |
onEvent(event) { | |
// log, UI, SSE, WebSocket... | |
} | |
}); |
也支持:
for await (const event of agent.runEvents(input)) { | |
// async iterable event stream | |
} |
事件包括:
agent_start | |
turn_start | |
thinking_delta | |
assistant_delta | |
tool_call_delta | |
message | |
tool_start | |
tool_end | |
turn_end | |
agent_end |
这让最小实现天然可以接 CLI、Web UI、HTTP SSE 或调试日志。后续加可观测性时,不需要再把 Agent Loop 拆开重写。
# 5. Reasoning 配置被放在 LLM 边界
当前 Agent 支持:
reasoning: { | |
effort: "high", | |
summary: "concise" | |
} |
也支持单次运行关闭:
await agent.run("...", { reasoning: false }); |
这个配置最终由 OpenAIResponsesClient 映射到 Responses API 的 reasoning 参数。Agent Loop 不需要知道 provider 的字段细节,只负责把通用配置传下去。
# 下一节做什么?
下一篇会实现 上下文管理器(Context Manager)。
现在的 Agent 每一轮都把完整 messages 原样交给模型,这在 demo 里没问题,但真实任务很快会遇到三个问题:
- 上下文窗口爆炸:工具日志、错误堆栈、长文件内容会迅速占满 token。
- 注意力漂移:模型看到的信息太多,反而找不到当前最关键的目标和约束。
- 缓存不友好:如果每轮 prompt 的前部不稳定,就很难命中 prompt cache。
所以第三篇会在当前 Agent Loop -> LLM Client 之间插入一个新模块:
messages | |
-> Context Manager | |
-> model-ready context | |
-> LLM Client |
它会负责:
- 控制哪些历史进入模型。
- 对工具结果做截断和摘要。
- 固定 system prompt、工具列表等稳定前缀。
- 把当前目标、最近 observation、关键约束放到更容易被模型注意到的位置。
到那一步,我们就开始从 “能跑的 Agent” 进入 “能跑长任务的 Agent”。
当然,我们现在的 Agent 还没有塞满上下文的能力,所以下一节中也会同步开发更多基础工具,让 Agent 有更多感知并影响世界的能力,获取到更多上下文。
# 小结
第二篇完成的是 Agent 的最小生命体征:模型会思考、工具能执行、结果能回填、循环能继续、答案能终止。它没有复杂 Harness,但已经具备 Agent 的核心结构。Codex 和 Pi 的生产实现都可以拆成这个最小循环:模型请求动作,宿主执行动作,观察结果回到模型,直到模型给出最终回答。后续所有模块,都会围绕这个循环继续生长。