这是「从零搭建 Agent」系列的第二篇。上一篇我们先搭了理论骨架:Agent Loop 是心脏,Harness 是围绕这个循环做上下文工程和注意力管理的系统。从这一篇开始,我们把理论实践到代码里:先不做复杂的 Harness,只实现一个最小但能跑起来的 Agent Loop。

# 这一节要实现什么?

第一篇里我们说,一个最小 Agent Loop 可以抽象成:

感知(Observe)→ 思考(Think)→ 行动(Act)

这句话听起来很像概念,但写成代码其实就是一个循环:

  1. 把用户输入和历史消息交给模型。
  2. 模型要么直接回答,要么请求调用工具。
  3. 如果模型请求工具,Agent 执行工具,把结果写回历史。
  4. 模型在下一轮看到工具结果,再继续回答或继续调用工具。
  5. 如果模型不再调用工具且目标已完成,循环结束。

这一节的目标是做一个最小的 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 failedtool not foundpermission 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_startturn_startmessage_starttool_execution_starttool_execution_endagent_end 这类事件。这样 CLI、TUI、Web UI、日志系统都可以订阅同一条 agent event stream。

所以我们这一版的实现策略很明确:

  1. 学 Codex 的调用逻辑:工具结果回填、工具错误回填、并行执行但顺序写回。
  2. 学 Pi 的形态:显式 TypeScript loop、清晰的 LLM 边界、事件驱动。
  3. 暂时不做复杂 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>;
}

工具调用被压到 ToolRegistryToolExecutor

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.tsrunInternal 里。

精简后是这样:

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) 的工程版本:

理论概念代码对应
Observemessages 里已有的 user、assistant、tool result
ThinkcompleteAssistant(...) 调用模型
ActexecuteToolCalls(...) 执行模型请求的工具
Observe againthis.messages.push(...toolResults) 将工具结果写回历史
Stopassistant 没有 tool call,或达到 maxTurns

注意这里的 messages: [...this.messages] ,每一轮模型看到的是当前完整 history。工具结果作为 role: "tool" 的消息存在,所以模型能基于上一次 action 的 observation 继续行动。


# LLM Client:隔离模型供应商

我们的 OpenAI 格式接口接入在 src/llm/openai-responses-client.ts 。它做两件事:

  1. 把内部消息结构转换成 Responses API 的 input
  2. 把 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.deltatext_delta
response.reasoning_summary_text.deltathinking_delta
response.function_call_arguments.deltatool_call_delta
response.completeddone

所以 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。模型可以基于它修正参数、换工具、或者告诉用户失败原因。


# 示例工具

当前内置了两个教学工具: calculatorget_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 里没问题,但真实任务很快会遇到三个问题:

  1. 上下文窗口爆炸:工具日志、错误堆栈、长文件内容会迅速占满 token。
  2. 注意力漂移:模型看到的信息太多,反而找不到当前最关键的目标和约束。
  3. 缓存不友好:如果每轮 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 的生产实现都可以拆成这个最小循环:模型请求动作,宿主执行动作,观察结果回到模型,直到模型给出最终回答。后续所有模块,都会围绕这个循环继续生长。