第 6 章:实习生的第一天
故事场景
你招了一个实习生。你给他一个任务:
"帮我把
src/下所有.js文件里的var改成const。"
如果这个实习生是真人,他的工作流程大概是:
- 想:这个任务需要我做什么?"哦,先找文件,再改内容。"
- 做 :打开终端,敲
ls src/*.js,看有哪些文件。 - 看 :拿到文件列表了。下一步呢?"呃,
find "var" a.js------不对,我应该用sed。" - 再做 :敲一串
sed命令。 - 检查:改完了,看看有没有遗漏。
- 报告:"老板,改了 5 个文件,14 处。"
每一步他不是提前想好的------他是走一步看一步。每一步的输出决定了下一步做什么。
这就是 Agent 循环。LLM 本身是一个只会"回答问题"的机器------你问它答。要让它变成一个能自主完成多步任务的 Agent,你需要一个循环:
while (任务没完成) {
让 LLM 看一下当前情况 → LLM 说"我要调这个工具" → 执行工具 → 把结果告诉 LLM → 继续
}
这一章是整个教程的心脏。前面的四章都在铺路------接电话、定规矩、流式输出、统一遥控器------现在,你要把这些路接在一起,让实习生(Agent)跑起来。
这一章要做什么
- 在
src/agent/types.ts中定义 Agent 专属类型(AgentTool、AgentLoopConfig、AgentEvent) - 实现
convertToLlm()------Agent 消息格式和 LLM 消息格式之间的翻译官 - 实现 Agent 循环 本身------
agentLoop():while 循环 + 流式响应 + 工具检测 + 执行 + 回环 - 写一个验证脚本------给 Agent 一个任务,观察它的完整工作过程
做完后你会看到:
🐣 Agent 启动
📝 用户: 用一句话解释什么是闭包
🤖 AI: 闭包是...(流式输出)
✅ Agent 完成
而当你给它一个需要调工具的任务时:
🐣 Agent 启动
📝 用户: 读一下 package.json,告诉我项目名
🤖 AI: 我需要先读文件(toolcall: readFile)
🔧 执行工具: readFile → 读取 package.json
📊 工具结果: { "name": "my-pi", ... }
🤖 AI: 项目名是 my-pi
✅ Agent 完成
实现
准备工作:确认上一章的产物
在开始之前,你的 src/ 目录应该已经有这些文件(第 1-5 章的产出):
src/
├── types.ts # Message, Context, Tool, AssistantMessageEvent 类型定义
├── event-stream.ts # EventStream 流式事件队列
├── models.ts # Provider 接口 + Models 集合 + createModels()
├── providers/
│ └── deepseek.ts # deepseekProvider()
├── agent/
│ └── types.ts # 第 5 章产物:AgentMessage, AgentContext, AgentTool, AgentEvent 等
└── index.ts # 入口文件
如果你还没有第 5 章的内容,下面是 src/agent/types.ts 的最小可用版本。把它复制进去:
typescript
// src/agent/types.ts
// Agent 专属的类型定义------这些是在上一章 Message/Tool 的基础上做的扩展
import type { Message, ToolResultMessage, AssistantMessage, Model } from "../types.js";
import type { Models } from "../models.js";
// ─── AgentTool:比基础 Tool 多了一个 execute 方法 ───
// 基础 Tool 只是"说明书"(名字、描述、参数),AgentTool 多了"实际执行的代码"
// 就像说明书 vs. 真正会做饭的厨师
export interface AgentTool<TParams = any, TResult = any> {
/** 工具名------必须唯一 */
name: string;
/** 工具描述------LLM 通过这个判断什么时候该用它 */
description: string;
/** 参数 schema------告诉 LLM 参数该长什么样 */
parameters: Record<string, any>;
/** 真正执行工具的代码 */
execute: (
toolCallId: string,
params: TParams,
signal?: AbortSignal,
) => Promise<AgentToolResult<TResult>>;
}
// ─── AgentToolResult:工具执行的结果 ───
// 工具可以返回文本、图片,以及结构化的额外信息(details)
export interface AgentToolResult<T = any> {
/** 返回给 LLM 的内容------文字或图片 */
content: Array<{ type: "text"; text: string } | { type: "image"; url: string }>;
/** 结构化详情------给 UI 或者其他工具看的 */
details?: T;
}
// ─── AgentMessage:Agent 内部使用的消息 ───
// 继承自基础的 Message 联合类型,为后续扩展预留空间
// 第 8 章会加入 steering、notification 等自定义消息
export type AgentMessage = Message;
// ─── AgentContext:Agent 的"工作台" ───
// 一次 Agent 运行需要的全部上下文
export interface AgentContext {
/** 系统提示词------Agent 的行为准则 */
systemPrompt: string;
/** 对话历史------之前所有轮的消息 */
messages: AgentMessage[];
/** Agent 可以使用的工具箱 */
tools?: AgentTool[];
}
// ─── AgentLoopConfig:控制 Agent 循环行为的配置 ───
export interface AgentLoopConfig {
/** 当前使用的模型 */
model: Model;
/** Models 集合------用来发 LLM 请求 */
models: Models;
/** 把 AgentMessage[] 转成 LLM 能理解的 Message[] ------消息格式翻译官 */
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
/** 工具执行模式------本章只支持 "sequential"(顺序执行) */
toolExecution?: "sequential" | "parallel";
/** 中断信号------用户想停下来时用 */
signal?: AbortSignal;
}
// ─── AgentEvent:Agent 工作过程中的"直播事件" ───
// 每一个事件都对应 Agent 工作流程中的一个时间点
// UI 层通过监听这些事件来更新界面
export type AgentEvent =
// Agent 生命周期
| { type: "agent_start" }
| { type: "agent_end"; messages: AgentMessage[] }
// 一轮对话的开始和结束(一轮 = 一次 LLM 调用 + 可能的工具执行)
| { type: "turn_start" }
| { type: "turn_end"; message: AssistantMessage; toolResults: ToolResultMessage[] }
// 消息生命周期
| { type: "message_start"; message: AgentMessage }
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
| { type: "message_end"; message: AgentMessage }
// 工具执行生命周期
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: ToolResultMessage; isError: boolean };
第一步:理解核心流程------Agent 是怎么"想→做→想→做"的
在写代码之前,用一张图理解整个循环:
用户说: "读 package.json,告诉我项目名"
│
▼
┌──────────────────────────────────────────┐
│ agentLoop() │
│ │
│ while (任务没完成) { │
│ ┌─────────────────────────────┐ │
│ │ 1. convertToLlm(messages) │ │
│ │ 把 Agent 的消息转成 │ │
│ │ LLM 能理解的格式 │ │
│ └──────────┬──────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ 2. models.stream(model, ctx)│ │
│ │ 流式调用 LLM │ │
│ └──────────┬──────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ 3. 处理流式事件 │ │
│ │ text_delta → 逐字打印 │ │
│ │ toolcall_end → 收集工具 │ │
│ │ done/error → 拿到完整回复│ │
│ └──────────┬──────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ 4. 检查 stopReason │ │
│ │ "stop" → 任务完成,退出 │ │
│ │ "toolUse" → 继续执行工具 │ │
│ └──────────┬──────────────────┘ │
│ ▼ (stopReason === "toolUse")│
│ ┌─────────────────────────────┐ │
│ │ 5. 执行工具调用 │ │
│ │ tool.execute(args) │ │
│ └──────────┬──────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ 6. 把工具结果追加到 messages │ │
│ │ messages.push(toolResult) │ │
│ └──────────┬──────────────────┘ │
│ │ │
│ └──→ 回到循环开头 │
│ (LLM 看到工具结果后继续思考) │
│ } │
└──────────────────────────────────────────┘
第二步:实现 convertToLlm()------消息翻译官
这是 Agent 和 LLM 之间的桥。为什么需要它?
Agent 的消息系统比 LLM 的丰富。Agent 内部可能有各种自定义消息类型------通知、状态更新、UI 指令------但 LLM 只认三种角色:user、assistant、toolResult。convertToLlm() 的工作就是过滤掉 LLM 不认识的,只留下它需要的。
就像你跟外国客户开会,旁边站着一个翻译------你说了一堆中文(包括自言自语、跟同事商量),翻译只把"要告诉客户的部分"翻成英语。
创建 src/agent/agent-loop.ts,先写这个翻译函数:
typescript
// src/agent/agent-loop.ts
import type {
Message, Context, Model,
AssistantMessage, ToolCall, ToolResultMessage,
AssistantMessageEvent,
} from "../types.js";
import type { Models } from "../models.js";
import type {
AgentMessage, AgentContext, AgentLoopConfig,
AgentEvent, AgentTool,
} from "./types.js";
// ─── 默认的消息转换器 ───
// AgentMessage[] → Message[]
// 为什么需要这个函数?
// Agent 内部有多种消息类型(以后还会有 steering、notification、artifact 等自定义消息)
// 但 LLM 的 API 只认三种角色:user、assistant、toolResult
// 这个函数就是"翻译官"------把 Agent 的丰富消息格式翻译成 LLM 的简单格式
// 不认识的类型直接过滤掉,不会报错
//
// 举例:
// 输入: [user消息, assistant消息, notification消息, toolResult消息]
// 输出: [user消息, assistant消息, toolResult消息]
// ↑ notification 被过滤掉了,因为 LLM 不认识它
export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
return messages.filter(
(m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult",
);
}
第三步:实现 Agent 循环------心脏部分
继续在 src/agent/agent-loop.ts 里写。这是本章的核心,也是最长的函数。
我们把 Agent 循环实现为一个 AsyncGenerator(异步生成器函数)。为什么不用普通函数 + 回调?
- AsyncGenerator 能让调用方用
for await (const event of agentLoop(...))逐个消费事件 - 事件和 LLM 调用是同一条时间线上的------生成器天然匹配这个模式
- 调用方可以随时
break退出(支持中断),而不需要额外的取消机制
先放一个辅助函数------updatePartialContent。它负责把流式接收到的每个文本/工具碎片拼接到 partialMessage 里。agentLoop 每收到一个事件就调它一次。
typescript
// ─── 辅助函数:把流式事件合并到 partialMessage ───
//
// LLM 的流式响应是这样过来的:
// text_start → "AI 开始写第一段文字"
// text_delta → "闭"
// text_delta → "包"
// text_end → "闭包是..."
// toolcall_start → "AI 要调工具了"
// toolcall_delta → '{"name"'
// toolcall_end → 完整的 ToolCall 对象
// done → 整个回复结束了
//
// 这个函数把每个"小碎片"拼接到正确的位置(通过 contentIndex 索引)
function updatePartialContent(
partial: AssistantMessage,
event: AssistantMessageEvent,
): AssistantMessage {
// start、done、error 三种事件不需要拼内容------直接处理
if (event.type === "start") return partial; // 流开始,不管
if (event.type === "done" || event.type === "error") { // 流结束,拿完整消息
return event.message;
}
// 确保 content 数组里对应位置有内容块
while (partial.content.length <= event.contentIndex) {
partial.content.push({ type: "text", text: "" } as any);
}
const block = partial.content[event.contentIndex];
switch (event.type) {
case "text_start":
partial.content[event.contentIndex] = { type: "text", text: "" };
break;
case "text_delta":
if (block.type === "text") {
partial.content[event.contentIndex] = { ...block, text: block.text + event.delta };
}
break;
case "text_end":
if (block.type === "text") {
partial.content[event.contentIndex] = { ...block, text: event.content };
}
break;
case "toolcall_start":
partial.content[event.contentIndex] = { type: "toolCall", id: "", name: "", arguments: {} };
break;
case "toolcall_end":
partial.content[event.contentIndex] = event.toolCall;
break;
}
return partial;
}
现在开始写主角------agentLoop 函数。这是整个教程的心脏。
typescript
// ─── Agent 循环:让 LLM 变成一个能自主行动的 Agent ───
//
// 这就是整个教程的心脏。它的工作方式跟真人实习生一样:
// 1. 接到任务
// 2. 想一想(调 LLM)
// 3. 需要动手就动手(执行工具)
// 4. 把动手的结果拿回来继续想(把 tool result 喂回 LLM)
// 5. 重复直到"我做好了"或"我做不了"
//
// 参数说明:
// prompts - 用户本轮说的话(可以是多条消息)
// context - 当前的对话上下文(历史 + 系统提示词 + 工具箱)
// config - 循环配置(模型、Models集合、转换函数等)
//
// 函数返回一个 AsyncGenerator------调用方逐个消费 AgentEvent
export async function* agentLoop(
prompts: AgentMessage[],
context: AgentContext,
config: AgentLoopConfig,
models: Models, // Models 集合(第 4 章)------agent loop 不持有它,由调用方传入
): AsyncGenerator<AgentEvent> {
// ─── 阶段 0:初始化 ───
// 告诉外界:Agent 开始干活了
yield { type: "agent_start" };
// 把用户本轮说的消息追加到对话历史上
// 注意:我们复制了一个新数组,不修改原来的 context.messages
const messages: AgentMessage[] = [...context.messages, ...prompts];
// 开始第一轮------通知外界一轮对话开始了
yield { type: "turn_start" };
// 逐条发射用户消息事件------UI 可以据此渲染用户说的话
for (const prompt of prompts) {
yield { type: "message_start", message: prompt };
yield { type: "message_end", message: prompt };
}
// 拿到消息转换函数------如果用户没提供,用默认的
const convertToLlm = config.convertToLlm ?? defaultConvertToLlm;
// ─── 主循环:Agent 会一直工作直到下面三种情况之一 ───
// 情况 1: AI 正常结束了(stopReason === "stop")
// 情况 2: AI 出错了或超长了(stopReason === "error" / "length")
// 情况 3: 被外部中断了(signal.aborted)
while (true) {
// 检查是否被中断------比如用户按了 Ctrl+C
if (config.signal?.aborted) {
yield { type: "agent_end", messages };
return;
}
// ─── 阶段 1:准备发请求 ───
// 把 Agent 的消息格式转成 LLM 能理解的格式
const llmMessages = await convertToLlm(messages);
// 构建 LLM 的上下文包------系统提示词 + 转换后的消息 + 工具定义
// 注意:传给 LLM 的工具定义是"说明书"版本(name + description + parameters)
// 不包含 execute 方法------LLM 不需要知道工具怎么执行的
const llmContext = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools?.map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters,
})),
};
// ─── 阶段 2:呼叫 LLM(流式) ───
const model = config.model;
const stream = models.stream(model, llmContext);
// 准备一个"半成品"助手消息------流式响应期间,内容会逐步填充
// partialMessage 会随着每个 text_delta 事件不断更新
let partialMessage: AssistantMessage = {
role: "assistant",
content: [],
usage: { input: 0, output: 0, total: 0 },
stopReason: "stop",
timestamp: Date.now(),
};
// 告诉外界:AI 开始回复了(此时内容还是空的)
yield { type: "message_start", message: partialMessage };
// 读取流式事件的 reader
const reader = stream.getReader();
// ─── 阶段 3:消费 LLM 的流式事件 ───
// 这里我们逐个处理 AI 的"打字过程"中的每个事件
while (true) {
const { done, value: event } = await reader.read();
if (done) break;
switch (event.type) {
case "text_delta": {
// AI 又吐出一个字(或一个词)------更新 partialMessage 的内容
// 为什么做 contentIndex?AI 的回复是一个数组,可能有多个文本块
// contentIndex=0 是第一个文本块,contentIndex=1 是第二个...
partialMessage = updatePartialContent(partialMessage, event);
// 通知外界内容更新了------UI 可以用这个事件刷新显示
yield { type: "message_update", message: partialMessage, assistantMessageEvent: event };
break;
}
case "toolcall_delta": {
// AI 在构造工具调用的参数------同样是逐片传来的 JSON
partialMessage = updatePartialContent(partialMessage, event);
break;
}
case "done": {
// LLM 正常完成了------拿到完整的 AssistantMessage
// event.message 是从 event-stream 的 onMessage 回调构造的完整消息
// 它包含了所有 content 片段、usage、stopReason
partialMessage = event.message;
break;
}
case "error": {
// LLM 这边出错了------也包装成一个 AssistantMessage
partialMessage = event.message;
break;
}
// start、text_start、text_end、toolcall_start、toolcall_end 等事件
// 它们的更新逻辑在 updatePartialContent 里统一处理
default: {
partialMessage = updatePartialContent(partialMessage, event);
break;
}
}
}
// ─── 阶段 4:拿到了完整的 AI 回复 ───
const assistantMessage = partialMessage;
// 发送"消息结束"事件------UI 可以据此结束渲染
yield { type: "message_end", message: assistantMessage };
// 把 AI 的回复加入对话历史------下次循环 LLM 能看到它
messages.push(assistantMessage);
// ─── 阶段 5:判断 AI 是否完成了任务 ───
// stopReason 是 LLM 的"停下来原因"
// - "stop": AI 觉得说完了,任务完成
// - "toolUse": AI 需要调工具------它的话还没说完
// - "length": 输出达到长度上限------AI 还想说但被截断了
// - "error": 出错了
// - "aborted": 被中断了
if (assistantMessage.stopReason !== "toolUse") {
// AI 没有要调工具------任务结束(正常结束、出错、或超长)
// 发送 turn_end → agent_end,然后退出循环
yield { type: "turn_end", message: assistantMessage, toolResults: [] };
yield { type: "agent_end", messages };
return;
}
// ─── 阶段 6:AI 要调工具了------执行工具调用 ───
// 从 assistantMessage.content 里提取所有 toolCall 类型的片段
const toolCalls = assistantMessage.content.filter(
(c): c is ToolCall => c.type === "toolCall",
);
// 收集本轮所有工具执行的结果
const toolResults: ToolResultMessage[] = [];
for (const toolCall of toolCalls) {
// 找到对应的工具定义------在 context.tools 里按名字匹配
const tool: AgentTool<any, any> | undefined = context.tools?.find(
(t) => t.name === toolCall.name,
);
let result: ToolResultMessage;
if (!tool) {
// 工具不存在------AI 可能幻觉了,叫了一个没注册的工具名
result = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: `错误:找不到工具 "${toolCall.name}"。可用工具:${context.tools?.map(t => t.name).join(", ") || "无"}` }],
isError: true,
timestamp: Date.now(),
};
} else {
// 通知外界:开始执行工具
yield {
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
};
// 真正执行工具------调用 tool.execute()
// 这是 Agent 真正"动手"的地方
try {
const executeResult = await tool.execute(
toolCall.id,
toolCall.arguments,
config.signal,
);
result = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: executeResult.content,
isError: false,
timestamp: Date.now(),
};
} catch (error) {
// 工具执行出错了------把错误包装成 toolResult
result = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [
{
type: "text",
text: `工具执行出错:${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
timestamp: Date.now(),
};
}
// 通知外界:工具执行完毕
yield {
type: "tool_execution_end",
toolCallId: toolCall.id,
toolName: toolCall.name,
result,
isError: result.isError,
};
}
// 把工具结果加入本轮结果列表
toolResults.push(result);
// 把工具结果加入对话历史------下次循环时 LLM 会看到
messages.push(result);
// 通知外界有新消息
yield { type: "message_start", message: result };
yield { type: "message_end", message: result };
}
// 本轮结束------发送 turn_end 事件
yield { type: "turn_end", message: assistantMessage, toolResults };
// ─── 回到循环开头 ───
// LLM 现在会看到对话历史里多了 toolResult 消息
// 它会根据工具执行结果决定下一步:继续调工具、还是给出最终答案
}
}
第四步:为什么这个设计是对的
| 设计决策 | 为什么这样做 |
|---|---|
while(true) 无限循环 |
Agent 不知道要调用多少次 LLM------可能 1 次就结束,可能 10 次。循环的退出条件由 LLM 的 stopReason 决定 |
| 用 AsyncGenerator | 事件是逐步产生的(LLM 在打字、工具在执行),生成器天然匹配"逐步产出"的模式。调用方用 for await 消费,代码清晰 |
| 不修改输入的 context | const messages = [...context.messages, ...prompts] 是浅拷贝------Agent 不污染传入的上下文,方便重试 |
| LLM 的 messages 和 Agent 的 messages 分开管理 | Agent 的 messages 包含更多类型(未来会有 steering 等),LLM 只看到过滤后的子集。转换在每次 LLM 调用前动态执行 |
| 工具执行在同一个 while 循环里 | 执行完工具后立即回到循环开头------LLM 马上就能看到工具结果并决定下一步。不需要额外的"等待 LLM 回来"机制 |
contentIndex 索引体系 |
LLM 的一次回复可能同时包含文本和工具调用------"我要读文件了(text),文件名是...(正在构造 toolCall),读它的原因是...(另一个 text)"。contentIndex 给每个片段标号,保证拼接不错位 |
| 工具错误不中断循环 | 工具执行出错时,我们把错误包装成 toolResult 的 error 消息传回 LLM。LLM 看到 "readFile 执行失败" 后会自己决定怎么办------重试、换个工具、还是告诉用户"我读不了" |
第五步:创建一个简单的测试工具------echo
为了能验证 Agent 循环,我们需要至少一个工具。创建一个极简的 echo 工具------它不做任何真实操作,只是把输入的参数原样返回。
创建 src/agent/test-tools.ts:
typescript
// src/agent/test-tools.ts
// 测试用的极简工具------用于验证 Agent 循环是否正常工作
// 正式的工具(读文件、写文件、搜索等)在第 9-11 章实现
import type { AgentTool } from "./types.js";
// ─── Echo 工具:把你说的话还给你 ───
// 这不是玩具------它的作用是验证 Agent 能否正确调用工具
// 当你看到 LLM 调了 echo 并且拿到了正确结果,说明整个循环链路是通的
export const echoTool: AgentTool<any, { receivedText: string }> = {
name: "echo",
label: "Echo", // UI 显示用的人读标签
description: "把输入的文本原样返回。用于测试工具调用是否正常工作。",
// 参数定义------告诉 LLM "这个工具需要一个 text 参数"
parameters: {
type: "object" as const,
properties: {
text: {
type: "string",
description: "要回显的文本",
},
},
required: ["text"],
},
// 执行逻辑------真的就是原样返回
// 注意:onUpdate 是可选的(AgentTool 接口要求,简单工具不需要用它)
async execute(toolCallId, params, signal, onUpdate?) {
// 模拟一点延迟------让我们能在验证时看清工具执行的顺序
await new Promise((resolve) => setTimeout(resolve, 100));
return {
content: [
{
type: "text" as const,
text: `[echo 工具返回] ${params.text}`,
},
],
details: { receivedText: params.text },
};
},
};
第六步:写验证脚本
创建 src/test-agent.ts------这个脚本会启动 Agent,发一个需要调工具的请求,然后打印所有事件:
typescript
// src/test-agent.ts
// 验证 Agent 循环是否正常工作
import { createModels } from "./models.js";
import { deepseekProvider } from "./providers/deepseek.js";
import { agentLoop, defaultConvertToLlm } from "./agent/agent-loop.js";
import { echoTool } from "./agent/test-tools.js";
import type { AgentMessage, AgentContext, AgentLoopConfig } from "./agent/types.js";
// ─── 1. 初始化 Models ───
const models = createModels();
models.setProvider(deepseekProvider());
const model = models.getModel("deepseek", "deepseek-v4-flash");
if (!model) {
console.error("❌ 找不到模型 deepseek/deepseek-v4-flash");
process.exit(1);
}
// ─── 2. 构建 Agent 上下文 ───
const context: AgentContext = {
systemPrompt: `你是一个编程助手。回答要简洁。
你可以使用 echo 工具来回显文本。当用户要求你"复述"或"重复"什么内容时,使用 echo 工具。
其他情况下直接回答即可。`,
messages: [],
tools: [echoTool],
};
// ─── 3. 用户说的话 ───
const userMessage: AgentMessage = {
role: "user",
content: "请用 echo 工具复述这句话:'Agent 循环搭建成功!'",
timestamp: Date.now(),
};
// ─── 4. Agent 循环配置 ───
const config: AgentLoopConfig = {
model,
convertToLlm: defaultConvertToLlm, // 使用默认的翻译器------过滤掉 LLM 不认识的消息
};
// ─── 5. 启动 Agent 循环并打印所有事件 ───
async function main() {
console.log("╔══════════════════════════════════════╗");
console.log("║ 🐣 Agent 循环验证脚本 ║");
console.log("╚══════════════════════════════════════╝\n");
console.log("📝 用户说:", typeof userMessage.content === "string" ? userMessage.content : "(非文本内容)");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
let turnCount = 0;
let totalTokens = { input: 0, output: 0 };
let printedLen = 0; // 追踪已打印的字符数------用于增量输出,避免中文被切碎
for await (const event of agentLoop([userMessage], context, config, models)) {
switch (event.type) {
case "agent_start":
console.log("🐣 Agent 启动\n");
break;
case "turn_start":
turnCount++;
console.log(`🔄 [第 ${turnCount} 轮开始]`);
break;
case "message_start":
if (event.message.role === "assistant") {
process.stdout.write("🤖 AI: ");
} else if (event.message.role === "toolResult") {
console.log(`\n🔧 工具返回 [${event.message.toolName}]:`);
}
break;
case "message_update":
// 流式更新------提取新增的文本并增量打印(不会切碎中文)
if (event.message.role === "assistant") {
const textParts = event.message.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("");
// textParts 是全量累积文本,printedLen 是上次打印到的位置
// slice 按字符切分,不是按字节------中文不会被劈成两半
const newPart = textParts.slice(printedLen);
if (newPart.length > 0) {
process.stdout.write(newPart);
printedLen = textParts.length;
}
}
break;
case "message_end":
if (event.message.role === "assistant") {
const assistantMsg = event.message;
console.log("");
printedLen = 0; // 重置------下一轮 AI 回复重新开始流式输出
if (assistantMsg.usage) {
totalTokens.input += assistantMsg.usage.input;
totalTokens.output += assistantMsg.usage.output;
}
console.log(` (stopReason: ${assistantMsg.stopReason})`);
// 检查是否有工具调用
const toolCalls = assistantMsg.content.filter(
(c): c is ToolCall => c.type === "toolCall"
);
if (toolCalls.length > 0) {
console.log("\n📞 AI 想要调用工具:");
for (const tc of toolCalls) {
console.log(` - ${tc.name}(${JSON.stringify(tc.arguments)})`);
}
console.log("");
}
}
break;
case "tool_execution_start":
console.log(`⏳ 执行工具: ${event.toolName}(${JSON.stringify(event.args)})`);
break;
case "tool_execution_end":
console.log(` ${event.isError ? "❌ 失败" : "✅ 完成"}`);
if (event.result.isError) {
const errorText = (event.result.content as any[])
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("");
console.log(` 错误信息: ${errorText}`);
}
break;
case "turn_end":
console.log(`\n🔄 [第 ${turnCount} 轮结束]`);
if (event.toolResults.length > 0) {
console.log(` 执行了 ${event.toolResults.length} 个工具`);
}
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
break;
case "agent_end":
console.log("🐣 Agent 结束");
console.log(`📊 总共 ${turnCount} 轮对话`);
console.log(`💰 Token 用量: ${totalTokens.input} + ${totalTokens.output} = ${totalTokens.input + totalTokens.output}`);
console.log(`📝 产生了 ${event.messages.length} 条消息`);
break;
}
}
console.log("\n✅ 验证完成!");
}
main().catch(console.error);
第七步:更新 package.json 的启动脚本
修改 package.json 的 scripts 部分------让 npm start 跑 Agent 验证而不是简单的对话:
json
{
"scripts": {
"start": "npx tsx src/test-agent.ts"
}
}
(如果你的 tsconfig.json 配置了 ESM,并且 package.json 里有 "type": "module",npx tsx 能直接跑 TypeScript。)
🔧 运行验证
bash
npm start
你应该看到类似这样的输出:
╔══════════════════════════════════════╗
║ 🐣 Agent 循环验证脚本 ║
╚══════════════════════════════════════╝
📝 用户说: 请用 echo 工具复述这句话:'Agent 循环搭建成功!'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🐣 Agent 启动
🔄 [第 1 轮开始]
🤖 AI:
📞 AI 想要调用工具:
- echo({"text":"Agent 循环搭建成功!"})
⏳ 执行工具: echo({"text":"Agent 循环搭建成功!"})
✅ 完成
🔧 工具返回 [echo]:
🔄 [第 2 轮开始]
(stopReason: toolUse)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🤖 AI: echo 工具返回了:'Agent 循环搭建成功!',循环已经正常工作了。
(stopReason: stop)
🔄 [第 2 轮结束]
执行了 0 个工具
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🐣 Agent 结束
📊 总共 2 轮对话
💰 Token 用量: 245 + 89 = 334
📝 产生了 6 条消息
✅ 验证完成!
试试不带工具的纯对话
把 src/test-agent.ts 里的用户消息改成不需要工具的:
typescript
const userMessage: AgentMessage = {
role: "user",
content: "用一句话解释什么是闭包",
timestamp: Date.now(),
};
再次运行 npm start,你应该看到只有 1 轮对话,AI 直接回答,不调任何工具。
试试多工具场景
如果 LLM 足够聪明(或者你的系统提示词引导得好),你可以加两个工具,看 Agent 如何连续调用它们。
❓ 常见问题
Agent 循环跑了一次就停了,没有继续调工具
检查 LLM 的回复是否是"我不需要调工具"而不是"我要调工具"。关键看 stopReason:
stopReason === "stop"→ AI 觉得说完了,Agent 退出stopReason === "toolUse"→ AI 要调工具,Agent 继续循环
如果 AI 明明应该调工具但 stopReason 是 stop,可能是:
- 系统提示词没有告诉 AI 它有工具可用
- 工具描述不够清晰,AI 不知道什么时候该用它
- 某些模型对于"在什么情况下调工具"比较保守
流式输出正常,但工具调用的参数是空的
工具调用的参数是通过 toolcall_delta 事件逐片传来的(JSON 片段)。确保 updatePartialContent 正确处理了 toolcall_delta 事件。检查 event-stream.ts 是否在 flush() 里正确拼接了完整的 ToolCall。
partialMessage.content[event.contentIndex] 报 undefined
这个错误说明 event.contentIndex 指向了数组中不存在的位置。可能原因:
- LLM 先发了
text_delta(contentIndex=1)然后才发text_start(contentIndex=0) updatePartialContent里的while循环没有正确扩展数组
修复:确保 updatePartialContent 的 while 循环在索引不存在时预先填充占位符。
循环跑了太多次------AI 一直在调工具不停
Agent 循环没有最大轮数限制。如果 LLM 陷入"调工具→不满意→再调→再不满意"的死循环,需要手动 Ctrl+C。第 7 章会加入最大轮数限制和重试控制。
📚 这一章你学到了
| 概念 | 一句话理解 |
|---|---|
| Agent 循环 | while(true) + 流式 LLM 调用 + 工具检测 + 执行 + 回环------让 AI 从"一问一答"变成"自主完成任务" |
| AsyncGenerator | 在函数里用 yield 逐个产出异步结果------天然适合"事件流"这种逐步产生的数据 |
convertToLlm() |
Agent 消息格式和 LLM 消息格式之间的翻译官------过滤掉 LLM 不认识的,只留它需要的 |
| 消息分离 | Agent 的 messages 比 LLM 的 messages 更丰富------转换在每次 LLM 调用前动态执行,互不污染 |
| contentIndex | LLM 的回复是数组,每个片段有自己的索引------text 和 toolCall 可以在同一条回复里共存 |
| stopReason 驱动 | Agent 循环的退出条件完全由 LLM 的 stopReason 决定------不硬编码轮数,让 AI 自己判断任务是否完成 |
| 工具错误转消息 | 工具执行失败不抛异常------把错误包装成 toolResult 喂回 LLM,让 AI 自己决定怎么处理 |
🔗 对应的 Pi 源码
packages/agent/src/agent-loop.ts:17-53---agentLoop()入口函数packages/agent/src/agent-loop.ts:95-143---runAgentLoop()和runAgentLoopContinue()packages/agent/src/agent-loop.ts:155-269---runLoop()核心循环(内层 tool loop + 外层 follow-up loop)packages/agent/src/agent-loop.ts:275-368---streamAssistantResponse()(LLM 调用 + 事件解析)packages/agent/src/agent-loop.ts:373-516---executeToolCalls()(顺序 + 并行工具执行)packages/agent/src/agent.ts:31-35---defaultConvertToLlm()默认消息转换packages/agent/src/types.ts:314---AgentMessage类型定义packages/agent/src/types.ts:397-404---AgentContext接口
本章的教学版简化了很多 Pi 实际功能,留在后续章节:
- 第 7 章:并行工具执行、
beforeToolCall/afterToolCall生命周期钩子 - 第 8 章:steering(中途打断)、followUp(后续追问)、消息队列
下一步
现在 Agent 能跑起来了------但全是过程式代码,一堆变量到处飞。下一章我们把 Agent 循环包成一个 Agent 类------管理状态、事件订阅、消息队列、中断控制。就像给实习生配了一个项目经理,帮你盯着他的每一步。