第 4 章:Agent Loop
Agent 的心脏
这是本书最重要的一章。前面的内容都是铺垫,后面的内容都会建立在这里之上。
Agent loop 会把语言模型从问答机器变成自主 agent。模式是:
while true:
1. Send messages to LLM (with tools)
2. Stream the response
3. If LLM wants to call tools:
a. Execute each tool
b. Add results to message history
c. Continue the loop
4. If LLM is done (no tool calls):
a. Break out of the loop
b. Return the final response
什么时候停止由 LLM 决定。它可能先调用一个工具,处理结果,再调用另一个工具,然后用文本回复。也可能在一个 turn 里调用三个工具,处理所有结果后再回复。Loop 会一直运行,直到 LLM 表示“我完成了,这是答案”。
Streaming vs. Generating
第 2 章里我们用了 generateText(),它会等完整回复生成后才返回。这对 evals 可以接受,但用户体验很差。用户希望实时看到 token 出现。
streamText() 会返回一个 async iterable,让你在 chunk 到达时逐个处理:
const result = streamText({
model,
messages,
tools: modelTools,
});
for await (const chunk of result.fullStream) {
if (chunk.type === "text-delta") {
// A piece of text arrived
process.stdout.write(chunk.text);
}
if (chunk.type === "tool-call") {
// The LLM wants to call a tool
console.log(`Tool: ${chunk.toolName}`, chunk.input);
}
}
fullStream 会给我们所有信息:text deltas、tool calls、finish reasons 等等。不同 chunk type 需要不同处理方式。
构建 Agent Loop
创建 src/agent/run.ts:
import { streamText, type ModelMessage } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { getTracer } from "@lmnr-ai/lmnr";
import { tools } from "./tools/index.ts";
import { executeTool } from "./executeTool.ts";
import { SYSTEM_PROMPT } from "./system/prompt.ts";
import { Laminar } from "@lmnr-ai/lmnr";
import type { AgentCallbacks, ToolCallInfo } from "../types.ts";
// Initialize Laminar for observability (optional - traces LLM calls)
Laminar.initialize({
projectApiKey: process.env.LMNR_API_KEY,
});
const apiKey = process.env.LLM_API_KEY;
if (!apiKey) {
throw new Error("Missing LLM_API_KEY in .env");
}
const provider = createOpenAI({
apiKey,
baseURL: process.env.LLM_BASE_URL,
});
const MODEL_NAME = process.env.LLM_MODEL ?? "qwen3.5-flash-2026-02-23";
function withoutSystemMessages(messages: ModelMessage[]): ModelMessage[] {
return messages.filter((message) => message.role !== "system");
}
function withoutToolExecutors<T extends Record<string, { execute?: unknown }>>(
toolSet: T,
): T {
return Object.fromEntries(
Object.entries(toolSet).map(([name, toolDef]) => [
name,
{ ...toolDef, execute: undefined },
]),
) as T;
}
export async function runAgent(
userMessage: string,
conversationHistory: ModelMessage[],
callbacks: AgentCallbacks,
): Promise<ModelMessage[]> {
const messages: ModelMessage[] = [
{ role: "system", content: SYSTEM_PROMPT },
...withoutSystemMessages(conversationHistory),
{ role: "user", content: userMessage },
];
let fullResponse = "";
const modelTools = withoutToolExecutors(tools);
while (true) {
const result = streamText({
model: provider.chat(MODEL_NAME),
messages,
tools: modelTools,
experimental_telemetry: {
isEnabled: true,
tracer: getTracer(),
},
});
const toolCalls: ToolCallInfo[] = [];
let currentText = "";
for await (const chunk of result.fullStream) {
if (chunk.type === "text-delta") {
currentText += chunk.text;
callbacks.onToken(chunk.text);
}
if (chunk.type === "tool-call") {
const input = "input" in chunk ? chunk.input : {};
toolCalls.push({
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
args: input as Record<string, unknown>,
});
callbacks.onToolCallStart(chunk.toolName, input);
}
}
fullResponse += currentText;
const finishReason = await result.finishReason;
// If the LLM didn't request any tool calls, we're done
if (finishReason !== "tool-calls" || toolCalls.length === 0) {
const responseMessages = await result.response;
messages.push(...responseMessages.messages);
break;
}
// Add the assistant's response (with tool call requests) to history
const responseMessages = await result.response;
messages.push(...responseMessages.messages);
// Execute each tool and add results to message history
for (const tc of toolCalls) {
const toolResult = await executeTool(tc.toolName, tc.args);
callbacks.onToolCallEnd(tc.toolName, toolResult);
messages.push({
role: "tool",
content: [
{
type: "tool-result",
toolCallId: tc.toolCallId,
toolName: tc.toolName,
output: { type: "text", value: toolResult },
},
],
});
}
}
callbacks.onComplete(fullResponse);
return withoutSystemMessages(messages);
}
我们一步一步看。
函数签名
export async function runAgent(
userMessage: string,
conversationHistory: ModelMessage[],
callbacks: AgentCallbacks,
): Promise<ModelMessage[]>
这个函数接收:
userMessage— 用户最新输入的消息conversationHistory— 之前所有消息,用于多轮对话callbacks— 通知 UI 的函数,例如 streaming tokens、tool calls 等
它返回更新后的 message history,调用方会把它保存起来,供下一轮对话使用。
构造 Messages
const messages: ModelMessage[] = [
{ role: "system", content: SYSTEM_PROMPT },
...withoutSystemMessages(conversationHistory),
{ role: "user", content: userMessage },
];
我们构造完整 message array:一个新的 system prompt、可复用的 conversation history、再加上新的 user message。withoutSystemMessages() 会把旧 system prompt 从 history 中移除,因为每次运行都应该只有一个最新的 system prompt。
随着工具被调用,这个数组会继续增长,tool results 会被追加进去。运行结束时,我们返回 withoutSystemMessages(messages),这样下一轮只会拿到可复用的 user、assistant 和 tool messages。
withoutToolExecutors() 会复制一份面向模型的 tools,并移除 execute 函数。模型仍然能看到工具名、描述和 schema,但 AI SDK 不会自动执行工具。这样工具执行就留在我们的 agent loop 里。
Loop
while (true) {
const result = streamText({ model, messages, tools: modelTools });
// ... process stream ...
if (finishReason !== "tool-calls" || toolCalls.length === 0) {
break; // LLM is done
}
// Execute tools, add results to messages, loop again
}
每次迭代会做这些事:
- 把当前 messages 和面向模型的 tool schemas 发送给 LLM
- 流式处理回复,收集文本和工具调用
- 检查
finishReason:"tool-calls"→ LLM 希望执行工具。执行工具,然后继续 loop。- 其他值,例如
"stop"、"length"→ LLM 已完成,退出 loop。
工具执行
for (const tc of toolCalls) {
const toolResult = await executeTool(tc.toolName, tc.args);
callbacks.onToolCallEnd(tc.toolName, toolResult);
messages.push({
role: "tool",
content: [{
type: "tool-result",
toolCallId: tc.toolCallId,
toolName: tc.toolName,
output: { type: "text", value: toolResult },
}],
});
}
对每个 tool call:
- 使用第 2 章写的 dispatcher 执行真实工具
- 通知 UI 工具已经完成
- 将结果作为
toolmessage 加入 history,并用原始toolCallId关联起来
toolCallId 很关键,它告诉 LLM 这个结果属于哪一次工具调用。没有它,LLM 就无法把结果和请求对应起来。
Callbacks
Callbacks 模式让 agent logic 和 UI 解耦:
callbacks.onToken(chunk.text); // Stream text to UI
callbacks.onToolCallStart(name, args); // Show tool execution starting
callbacks.onToolCallEnd(name, result); // Show tool result
callbacks.onComplete(fullResponse); // Signal completion
Agent 不需要知道 UI 是终端、网页还是测试 harness。它只需要调用 callbacks。AI SDK 本身也使用类似模式。
测试 Loop
用一个简单脚本测试一下。更新 src/index.ts:
import { runAgent } from "./agent/run.ts";
import type { ModelMessage } from "ai";
const history: ModelMessage[] = [];
const result = await runAgent(
"What files are in the current directory? Then read the package.json file.",
history,
{
onToken: (token) => process.stdout.write(token),
onToolCallStart: (name, args) => {
console.log(`\n[Tool] ${name}`, JSON.stringify(args));
},
onToolCallEnd: (name, result) => {
console.log(`[Result] ${name}: ${result.slice(0, 100)}...`);
},
onComplete: () => console.log("\n[Done]"),
onToolApproval: async () => true, // Auto-approve for now
},
);
console.log(`\nTotal messages: ${result.length}`);
运行:
npm run start
你应该会看到 agent:
- 调用
listFiles查看目录内容 - 调用
readFile读取package.json - 根据发现的内容生成总结回复
这就是 loop 在工作。LLM 可能跨多个 loop iteration 发起两次工具调用,拿到结果后,再综合成一个连贯回复。
Message History
Loop 结束后,messages array 大概会长这样:
[system] "You are a helpful AI assistant..."
[user] "What files are in the current directory? Then read..."
[assistant] (tool call: listFiles)
[tool] "[dir] node_modules\n[dir] src\n[file] package.json..."
[assistant] (tool call: readFile, text: "Let me read...")
[tool] "{ \"name\": \"agi\", ... }"
[assistant] "Your project has the following files... The package.json shows..."
这就是完整 conversation history。LLM 每次迭代都会看到它,所以才能保持上下文。这也是为什么第 7 章的 context management 很重要:history 会随着每次交互不断变长。
错误处理
真实实现应该处理 stream errors。下面是加入错误处理后的增强版本:
try {
for await (const chunk of result.fullStream) {
if (chunk.type === "text-delta") {
currentText += chunk.text;
callbacks.onToken(chunk.text);
}
if (chunk.type === "tool-call") {
const input = "input" in chunk ? chunk.input : {};
toolCalls.push({
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
args: input as Record<string, unknown>,
});
callbacks.onToolCallStart(chunk.toolName, input);
}
}
} catch (error) {
const streamError = error as Error;
if (!currentText && !streamError.message.includes("No output generated")) {
throw streamError;
}
}
如果 stream 出错但我们已经拿到了一些文本,仍然可以使用这些文本。如果错误是 “no output generated” 且没有任何文本,我们可以提供 fallback message。这样 agent 对临时 API 问题会更有韧性。
小结
这一章你完成了:
- 用 streaming 构建核心 agent loop
- 理解 stream → detect tool calls → execute → loop 模式
- 使用 callbacks 解耦 agent logic 和 UI
- 处理随着每次工具调用增长的 message history
- 为 stream failures 添加错误处理
这是 agent 的引擎。后面的所有内容,包括更多工具、上下文管理、人工审批,都会插入这个 loop。下一章,我们会构建多轮评测,测试完整 loop。
下一章:第 5 章:多轮评测 →