第 5 章:Agent 的日记本
故事场景
你的 Provider 层已经跑得挺顺了------能流式对话,换 AI 公司只改一行代码。
现在老板走过来,看了看屏幕:"不错,但它只会聊天。让它改代码试试。"
你挠了挠头。让 AI 调用工具?倒是想过,但------
"等一下,"你心里打鼓,"我连 Agent 长什么样都还没想清楚。"
- 工具调用要告诉谁?总得有个"主体"来跑这个循环吧?
- 循环过程中,外面的人怎么知道 Agent 在干嘛------是正在思考,还是在调工具,还是卡住了?
- 如果用户中途说"停!换个思路",这条消息该怎么塞进去?
- AI 回复了一次之后,怎么判断它是要继续还是已经完成了?
你看着 Provider 层的代码,突然意识到一个尴尬的事实:
Provider 只负责"拨通电话"。但拨通之后谁负责对话------怎么聊、聊几轮、什么时候挂电话------全都没定义。
你需要一个"Agent"层。但你甚至不知道 Agent 的数据结构长什么样。就像你要招一个实习生,但连他的工位(状态)、日记本(消息)、工作流程(事件)都还没画出来。
这一章,我们不写一行业务逻辑。我们先把"实习生"的档案填了------他需要记什么、他干活时会发出什么信号、他能用什么工具。
"数据结构设计得好,代码自己就写完了。" ------某位不肯透露姓名的秃头架构师
这一章要做什么
创建 src/agent/types.ts------定义 Agent 层的全量类型。做完后你的 Agent 虽然还不会动,但它的"骨骼"已经搭好了:你能告诉它有哪些工具、看它的状态、监听它的一举一动。
用大白话说:给 Agent 发一张"员工登记表"------姓名、工号、会什么技能、工作时会发出什么信号,全都写清楚。
实现
这章要新建哪个文件
src/
agent/
types.ts ← 我们新建的
types.ts ← 第 2 章写的 LLM 类型(Message、Context、Tool、Model)
models.ts ← 第 4 章写的 Provider 层
providers/
deepseek.ts ← 第 4 章写的
event-stream.ts ← 第 3 章写的
index.ts ← 入口
src/agent/types.ts 依赖 src/types.ts 的基础类型,但它是 Agent 专属的------就像"公司内部人事系统"跟"外部客户 CRM"是两套概念,各有各的数据结构。
为什么需要一套独立的 Agent 类型
打个比方:
| LLM 层(第 2 章) | Agent 层(本章) | |
|---|---|---|
| Message | 用户说的话、AI 的回复、工具结果------LLM 能理解的格式 | 除了 LLM 消息,还可能有"系统通知""UI 状态""用户中断"------Agent 内部需要更丰富的消息类型 |
| Tool | 一份"说明书":名字、描述、参数 | 除了说明书,还要有"腿"------真正执行工具的 execute 函数 |
| 事件 | AssistantMessageEvent:流式输出的文本增量 |
AgentEvent:Agent 的一生------启动、思考、调工具、完成、出错 |
| 状态 | 无状态------每次调用都是独立的一次 API 请求 | 有状态------系统提示词、模型、消息历史、正在流式输出的内容、正在跑的工具 |
换句话说:LLM 层是"单次通话",Agent 层是"整个工作流程"。 一个只管每次电话讲得好不好,另一个管整个项目怎么推进。
第一步:AgentMessage------比 LLM 消息多个"扩展槽"
Agent 内部的消息不一定是 LLM 认识的标准格式。举个例子:
- "用户中断了当前操作"------这不是 LLM 的 user/assistant/toolResult 任何一种
- "系统提醒:代码已自动保存"------同理
- "上下文快满了,准备压缩历史"------也不是 LLM 消息
但这些"非标准消息"在 Agent 内部是有用的。怎么给 LLM 的 Message 类型开个"扩展槽"?
Pi 的做法是 TypeScript 声明合并(declaration merging)。这玩意儿听起来唬人,其实就两句话:
TypeScript 里,同名
interface会自动合并成一个。你写两次interface CustomAgentMessages {},它们的属性会合到一起。
用这个特性,我们定义一个空的 CustomAgentMessages 接口。应用层想要加自定义消息类型时,再通过 declare module 往里面塞。
为什么这样做而不是直接用 Message | MyExtraMessage?
因为你的 Agent 库是一个通用框架------你不知道使用者将来想加什么类型的消息。声明合并让使用者在不修改库源码的情况下扩展类型。
创建 src/agent/types.ts,先写第一部分:
typescript
// ─── src/agent/types.ts ───
// Agent 层的类型定义------Agent 的"员工档案"
import type {
// 基础 LLM 类型------第 2 章定义的
Message, // 用户/AI/工具消息的联合类型
AssistantMessage, // AI 回复消息
AssistantMessageEvent,// 流式输出事件(第 3 章)
ToolResultMessage, // 工具执行结果消息
TextContent, // 文本内容块
Model, // 模型描述符(第 4 章)
Context, // LLM 对话上下文(第 2 章)
} from "../types.js";
import type { TSchema, Static } from "@sinclair/typebox";
// ═══════════════════════════════════════════════════════════════
// 1. AgentMessage------Agent 内部的消息类型
// ═══════════════════════════════════════════════════════════════
/**
* 自定义 Agent 消息的扩展接口。
*
* 默认是空的。应用层通过 TypeScript 声明合并(declaration merging)
* 来添加自定义消息类型。
*
* 通俗解释:
* 一个"活页本"------一开始是空白的,你想加什么类型的消息就加一页进去。
* TypeScript 的 interface 合并特性保证了多页会自动合成一本。
*
* @example
* // 在你的应用代码里(比如 src/app-types.ts):
* declare module "./agent/types.js" {
* interface CustomAgentMessages {
* notification: { type: "notification"; text: string; timestamp: number };
* interruption: { type: "interruption"; reason: string; timestamp: number };
* }
* }
* // 然后 AgentMessage 就会自动包含这两种新类型
*/
export interface CustomAgentMessages {
// 空接口------等应用层通过声明合并往里加
}
/**
* AgentMessage:LLM 标准消息 + 应用自定义消息的联合类型。
*
* 这是 Agent 内部传递消息的统一类型。
* 发给 LLM 之前,通过 convertToLlm 函数筛掉 LLM 不认识的类型。
*
* 类比:公司内部的"综合档案"------包含了跟客户沟通的正式记录(LLM 消息),
* 也包含内部便签、审批单(自定义消息)。发给客户时只挑正式记录。
*/
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
CustomAgentMessages[keyof CustomAgentMessages]是什么鬼?
keyof CustomAgentMessages拿到接口的所有键(比如"notification" | "interruption"),然后
[keyof ...]用这些键去取对应的值类型。效果就是:把接口里所有属性的值类型取出来,拼成一个联合类型。
因为
CustomAgentMessages初始是空的({}),keyof {}是never,所以默认情况下
AgentMessage = Message | never = Message------就是纯粹的 LLM 消息。
第二步:AgentTool------能"动手"的工具
第 2 章的 Tool 类型只是一份说明书------告诉 LLM "我能读文件,参数是 path"。但谁来真正执行 fs.readFile(path) 呢?
AgentTool 在 Tool 的基础上加了 execute 函数------这就是"腿"。
继续在 src/agent/types.ts 里写:
typescript
// ═══════════════════════════════════════════════════════════════
// 2. AgentTool------"会跑"的工具
// ═══════════════════════════════════════════════════════════════
/**
* 工具执行模式:
* - "sequential":一个个来,前一个完成后一个才开始(比如写文件,顺序很重要)
* - "parallel":同时跑,谁先完成谁先报结果(比如读多个不相关的文件)
*/
export type ToolExecutionMode = "sequential" | "parallel";
/**
* 工具执行完返回的结果。
*
* - content:给 LLM 看的文本/图片("文件内容是...")
* - details:给 UI 或日志看的结构化数据(完整的 JSON 对象,可以做渲染)
* - terminate:true 表示"干完了,别再调其他工具了"
*/
export interface AgentToolResult<TDetails = unknown> {
/** 返回给模型看的文本或图片内容 */
content: TextContent[];
/** 结构化详情------日志、UI 渲染用,LLM 看不到 */
details: TDetails;
/** 提示 Agent 这轮工作该停了 */
terminate?: boolean;
}
/**
* 工具执行过程中,用于推送"半成品"的回调。
*
* 比如读一个大文件,可以先推送前 100 行让 UI 显示出来,
* 等全读完了再推送最终结果。
*/
export type AgentToolUpdateCallback<TDetails = any> = (
partialResult: AgentToolResult<TDetails>
) => void;
/**
* AgentTool------Agent 真正能调用的工具。
*
* 和 Tool(第 2 章)的区别:
* Tool 只是"说明书"(name + description + parameters)
* AgentTool 在说明书基础上加了"动手能力"(execute 函数)
*
* 类比:
* Tool = 菜谱("宫保鸡丁:鸡丁、花生、酱油...")
* AgentTool = 菜谱 + 一个能颠勺的厨子
*/
export interface AgentTool<
TParameters extends TSchema = TSchema,
TDetails = unknown
> {
/** 工具名------LLM 会用它来指定调哪个工具 */
name: string;
/** 工具描述------LLM 靠这段文字判断什么时候该用它 */
description: string;
/** 参数的 typebox schema------LLM 必须按这个格式填参数 */
parameters: TParameters;
/** 人类可读的标签------在 UI 上显示用 */
label: string;
/**
* 可选:在 schema 校验前对原始参数做一次"剪裁"。
* 比如 LLM 传的参数格式有点歪,你在这里把它掰正。
*/
prepareArguments?: (args: unknown) => Static<TParameters>;
/**
* 真正执行工具的异步函数。
*
* 参数说明:
* toolCallId: 这次调用的唯一 ID(用来跟结果配对)
* params: 校验并剪裁后的参数
* signal: 取消信号------用户说"停",工具也该停
* onUpdate: 推送半成品的回调
*
* 返回:AgentToolResult<TDetails>
* 执行失败直接 throw,不要在 content 里编码错误------
* Agent 循环会来 catch 并生成错误消息。
*/
execute: (
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
/**
* 单个工具的并行策略覆盖。
* 不设就用全局的 toolExecution 配置。
* - "sequential":这个工具必须排队执行
* - "parallel":这个工具可以和其他工具同时跑
*/
executionMode?: ToolExecutionMode;
}
为什么 execute 失败直接 throw 而不是在 content 里放错误信息?
因为工具的结果里,content 是要发给 LLM 看的。如果工具执行失败了,我们想让 Agent 循环统一生成 一条 isError: true 的 ToolResultMessage,而不是让每个工具自己决定"错误长什么样"。这是"关注点分离"------工具只管干活,框架管报错。
第三步:AgentState------Agent 的"健康仪表盘"
Agent 在运行中需要暴露一些状态给外界看------就像车上的仪表盘:速度、油量、水温。
继续在 src/agent/types.ts 里写:
typescript
// ═══════════════════════════════════════════════════════════════
// 3. AgentState------Agent 的运行时"仪表盘"
// ═══════════════════════════════════════════════════════════════
/**
* 思考/推理深度级别。
*
* 部分模型(如 DeepSeek V4 Pro)支持不同深度的推理。
* - "off":不想,直接答
* - "minimal":稍微想一下
* - "low"/"medium"/"high":逐级加深
* - "xhigh":往死里想(不是所有模型都支持)
*/
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
/**
* Agent 的公开状态。
*
* 外部(UI、CLI、测试)通过读这个对象来了解 Agent 现在在干嘛。
*
* 类比:手术室外的显示屏------"手术中"、"已消毒"、"预计还需 20 分钟"
*/
export interface AgentState {
/** 系统提示词------每次 LLM 请求都会带上 */
systemPrompt: string;
/** 当前使用的模型 */
model: Model;
/** 当前推理深度 */
thinkingLevel: ThinkingLevel;
/**
* 工具列表------用 getter/setter 让实现层可以在赋值时做"浅拷贝"。
* setter 接收一个数组,内部存一份拷贝------防止外部偷偷改数组元素。
*/
set tools(tools: AgentTool<any>[]);
get tools(): AgentTool<any>[];
/**
* 消息历史------同理,用 getter/setter 做浅拷贝。
*/
set messages(messages: AgentMessage[]);
get messages(): AgentMessage[];
/**
* 是否正在处理请求。
* true 期间,不要调用 prompt(),否则会报错。
* 即使 agent_end 发出后,也要等所有事件订阅者处理完毕,
* isStreaming 才会变回 false。
*/
readonly isStreaming: boolean;
/**
* 当前正在流式输出的消息(不完整)。
* 流式过程中,这里能看到 AI 的"草稿"。
* 流式结束后这个值变成 undefined。
*/
readonly streamingMessage?: AgentMessage;
/**
* 正在执行中的工具调用 ID 集合。
* 空集合 = 没在跑工具。
* 可以用于 UI 显示"正在读文件..."
*/
readonly pendingToolCalls: ReadonlySet<string>;
/**
* 最近一次出错/中断的错误消息。
* 正常时是 undefined,出问题时这里会有描述。
*/
readonly errorMessage?: string;
}
为什么 tools 和 messages 用 getter/setter 而不是普通属性?
typescript
// 普通属性的问题:引用泄露
agentState.messages.push(newMessage); // 绕过了 Agent 的控制,不讲武德
// getter/setter 的做法:内部存一份,别人改不了
// Agent 实现层:
private _messages: AgentMessage[] = [];
set messages(msgs: AgentMessage[]) {
this._messages = [...msgs]; // 浅拷贝,外面删改原数组不影响内部
}
get messages(): AgentMessage[] {
return this._messages;
}
这就是所谓的 defensive copy(防御性拷贝)------你不是在防朋友,你是在防你自己三个月后的凌晨三点改 bug 时。
第四步:AgentContext------每次呼叫 LLM 前打包的数据
AgentContext 是一个快照------Agent 发给 LLM 之前,把系统提示词 + 消息历史 + 可用工具打成一个包。
和 LLM 的 Context(第 2 章)很像,但消息类型用的是 AgentMessage[] 而不是 Message[]------因为 Agent 内部可能有自定义消息类型。
继续在 src/agent/types.ts 里写:
typescript
// ═══════════════════════════════════════════════════════════════
// 4. AgentContext------发给 LLM 的"包裹"
// ═══════════════════════════════════════════════════════════════
/**
* Agent 循环内部使用的上下文快照。
*
* 注意和 LLM Context 的区别:
* LLM Context.messages 是 Message[](纯 LLM 消息)
* AgentContext.messages 是 AgentMessage[](可能包含自定义消息)
*
* 转换由 AgentLoopConfig.convertToLlm 完成。
*/
export interface AgentContext {
/** 系统提示词 */
systemPrompt: string;
/** 对话历史------AgentMessage 级别,还可能包含自定义消息 */
messages: AgentMessage[];
/** 本次可用的工具 */
tools?: AgentTool<any>[];
}
第五步:AgentEvent------Agent 对外广播的"语言"
Agent 运行的时候,外界需要知道它在干嘛。靠什么?事件。
Pi 的 Agent 事件是一个联合类型------所有事件都有一个 type 字段做区分(这就是 TypeScript 的 discriminated union)。外界只订阅自己感兴趣的事件类型。
继续在 src/agent/types.ts 里写:
typescript
// ═══════════════════════════════════════════════════════════════
// 5. AgentEvent------Agent 的"广播系统"
// ═══════════════════════════════════════════════════════════════
/**
* Agent 运行过程中发出的所有事件类型。
*
* 时间线:(一次 prompt 调用)
*
* ┌─ agent_start ─────────────────────────────────┐
* │ │
* │ ┌─ turn_start ──────────────────────────┐ │
* │ │ │ │
* │ │ message_start (用户消息) │ │
* │ │ message_end (用户消息) │ │
* │ │ │ │
* │ │ ── LLM 开始回复 ── │ │
* │ │ message_start (AI 回复) │ │
* │ │ message_update ... (流式递增) │ │
* │ │ message_end (AI 回复) │ │
* │ │ │ │
* │ │ ── 如果 AI 要调工具 ── │ │
* │ │ tool_execution_start (readFile) │ │
* │ │ tool_execution_update ... │ │
* │ │ tool_execution_end (readFile) │ │
* │ │ (tool 可能有多个) │ │
* │ │ │ │
* │ │ turn_end(本轮结束,含工具结果) │ │
* │ └────────────────────────────────────────┘ │
* │ │
* │ (如果还有下一轮,重复上面的 turn_start...) │
* │ │
* └─ agent_end(本次 prompt 全部结束) ─────────────┘
*/
export type AgentEvent =
// ─── Agent 生命周期 ───
/** Agent 开始处理一次 prompt */
| { type: "agent_start" }
/** Agent 结束------messages 是本次新增的消息 */
| { type: "agent_end"; messages: AgentMessage[] }
// ─── Turn 生命周期 ───
/**
* 一轮(turn)= AI 回复一次 + 执行其中所有工具调用
*/
/** 新的一轮开始 */
| { type: "turn_start" }
/** 本轮结束------message 是 AI 的最终回复,toolResults 是工具执行的结果 */
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
// ─── 消息生命周期 ───
/** 一条消息开始(用户消息、AI 回复、工具结果都会触发) */
| { type: "message_start"; message: AgentMessage }
/**
* 消息更新------只在 AI 流式回复时触发。
* assistantMessageEvent 是原始的流式事件,包含增量文本。
*/
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
/** 一条消息完成 */
| { type: "message_end"; message: AgentMessage }
// ─── 工具执行生命周期 ───
/**
* 工具开始执行。
* toolCallId: 唯一调用 ID
* toolName: 工具名
* args: 校验后的参数
*/
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown }
/**
* 工具执行过程中的部分结果(比如读大文件时报告进度)。
* partialResult 是 AgentToolResult 的快照。
*/
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: unknown; partialResult: unknown }
/**
* 工具执行完成。
* result: 最终结果
* isError: 是否执行失败
*/
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: ToolResultMessage; isError: boolean };
为什么事件要用 discriminated union 而不是 class 继承?
typescript
// 如果用类继承来处理事件:
class AgentEvent { }
class TurnStart extends AgentEvent { }
// ... 你每次处理事件都要 instanceof 检查,而且打包体积更大
// discriminated union:所有事件扁平化,靠 type 字段区分
// TypeScript 的 switch 能自动收窄类型------写了 "agent_start" 之后,
// 下面的事件字段类型自动推断出来了
function handleEvent(event: AgentEvent) {
switch (event.type) {
case "agent_start":
// 这里 event 的类型自动收窄为 { type: "agent_start" }
console.log("Agent 启动了");
break;
case "agent_end":
// 这里 event.messages 有智能提示
console.log("Agent 结束了,新增了", event.messages.length, "条消息");
break;
// ...
}
}
这是 TypeScript 的一个杀手级特性------几行代码写出的联合类型,比一套类继承体系好维护十倍。
第六步:AgentLoopConfig------Agent 循环的"设置面板"
最后是配置------Agent 循环需要知道怎么跑、用什么模型、消息怎么转换、在哪里介入。
继续在 src/agent/types.ts 里写:
typescript
// ═══════════════════════════════════════════════════════════════
// 6. AgentLoopConfig------循环的"操作手册"
// ═══════════════════════════════════════════════════════════════
/**
* Agent 循环的配置。包含 LLM 调用设置和各种生命周期钩子。
*
* 类比:洗衣机的操作面板------水温、转速、洗涤模式、定时。
*/
export interface AgentLoopConfig {
// ─── LLM 调用选项 ───
/** 温度(0-2,越高越随机) */
temperature?: number;
/** 最大输出 token 数 */
maxTokens?: number;
/** 取消信号------调用方可以 abort 中断请求 */
signal?: AbortSignal;
// ─── 模型和转换 ───
/** 使用的模型 */
model: Model;
/**
* 把 AgentMessage[] 转成 LLM 认识的 Message[]。
*
* 这是关键桥接函数------Agent 内部可能有自定义消息类型
* (通知、状态、中断...),LLM 不认识这些。这个函数负责:
* 1. 筛掉 LLM 不认的(比如 UI 通知)
* 2. 把自定义消息转换成 LLM 标准消息
*
* 类比:内部文件(AgentMessage) → 对外邮件(Message)
*
* 合同:不能 throw,不能 reject。返回安全降级值。
*
* @example
* convertToLlm: (messages) => messages.flatMap(m => {
* // 标准 LLM 消息直接放行
* if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
* return [m];
* }
* // 自定义消息------这里做转换
* // 当前我们的 CustomAgentMessages 是空的,暂时不会有自定义类型
* // 但钩子留在这里,后面章节会用到
* return [];
* })
*/
convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
/** 工具执行模式------并行还是串行,默认 "parallel" */
toolExecution?: ToolExecutionMode;
// ─── 可选钩子:插入自定义逻辑 ───
/**
* 每一轮结束后调用。返回 true 则 Agent 直接退出,不再进入下一轮。
* 用于"该停的时候主动喊停"------比如消息太多、token 快超了。
*/
shouldStopAfterTurn?: (
context: {
message: AssistantMessage;
toolResults: ToolResultMessage[];
context: AgentContext;
}
) => boolean | Promise<boolean>;
/**
* 动态获取 API Key------适用于短时间内密钥会过期的情况。
* 如果不需要动态刷新,就可以忽略这个字段。
*/
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
/**
* 工具执行前调用。返回 `{ block: true }` 可以阻止执行。
* 用于安全审批------"这个 rm -rf / 我不同意!"
*/
beforeToolCall?: (
context: {
assistantMessage: AssistantMessage;
toolCall: { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> };
args: unknown;
context: AgentContext;
},
signal?: AbortSignal,
) => Promise<{ block?: boolean; reason?: string } | undefined>;
/**
* 工具执行后调用。可以覆盖工具结果。
* 比如文件保存后自动格式化、做一次 lint 检查。
*/
afterToolCall?: (
context: {
assistantMessage: AssistantMessage;
toolCall: { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> };
args: unknown;
result: AgentToolResult;
isError: boolean;
context: AgentContext;
},
signal?: AbortSignal,
) => Promise<{
content?: TextContent[];
details?: unknown;
isError?: boolean;
terminate?: boolean;
} | undefined>;
}
为什么 convertToLlm 是必填的?
因为 AgentContext 里的消息是 AgentMessage[],LLM API 要的是 Message[]。这个转换不是"可选的优化"------不转就发不出去。放在 AgentLoopConfig 里作为必填项,强迫使用者在创建配置时就想清楚"我的 AgentMessage 怎么变成 LLM Message"。
这也是故意设计得不自动化------因为转换逻辑里可能有业务判断(哪些消息要跳过、自定义消息怎么映射),这些判断不应该由底层库替你瞎猜。
完整的 src/agent/types.ts
把上面 6 步的代码拼起来,就是完整的文件。注意 import 区域要集中放在文件顶部:
typescript
// ─── src/agent/types.ts ───
// Agent 层的类型定义------"Agent 的员工档案"
//
// 这层在"LLM 通信层"之上,定义 Agent 循环所需的所有类型。
// 本章只定义类型骨架,不包含任何实现代码。
import type {
Message,
AssistantMessage,
AssistantMessageEvent,
ToolResultMessage,
TextContent,
Model,
} from "../types.js";
import type { TSchema, Static } from "@sinclair/typebox";
// ═══════════════════════════════════════════════════════════════
// 1. CustomAgentMessages ------ 声明合并的"扩展槽"
// ═══════════════════════════════════════════════════════════════
export interface CustomAgentMessages {
// 空接口,应用层通过 declaration merging 往里加自定义消息类型
}
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
// ═══════════════════════════════════════════════════════════════
// 2. AgentTool ------ 会"动手"的工具
// ═══════════════════════════════════════════════════════════════
export type ToolExecutionMode = "sequential" | "parallel";
export interface AgentToolResult<TDetails = unknown> {
content: TextContent[];
details: TDetails;
terminate?: boolean;
}
export type AgentToolUpdateCallback<TDetails = any> = (
partialResult: AgentToolResult<TDetails>
) => void;
export interface AgentTool<
TParameters extends TSchema = TSchema,
TDetails = unknown
> {
name: string;
description: string;
parameters: TParameters;
label: string;
prepareArguments?: (args: unknown) => Static<TParameters>;
execute: (
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
executionMode?: ToolExecutionMode;
}
// ═══════════════════════════════════════════════════════════════
// 3. AgentState ------ 运行时"仪表盘"
// ═══════════════════════════════════════════════════════════════
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export interface AgentState {
systemPrompt: string;
model: Model;
thinkingLevel: ThinkingLevel;
set tools(tools: AgentTool<any>[]);
get tools(): AgentTool<any>[];
set messages(messages: AgentMessage[]);
get messages(): AgentMessage[];
readonly isStreaming: boolean;
readonly streamingMessage?: AgentMessage;
readonly pendingToolCalls: ReadonlySet<string>;
readonly errorMessage?: string;
}
// ═══════════════════════════════════════════════════════════════
// 4. AgentContext ------ 发给 LLM 的"包裹"
// ═══════════════════════════════════════════════════════════════
export interface AgentContext {
systemPrompt: string;
messages: AgentMessage[];
tools?: AgentTool<any>[];
}
// ═══════════════════════════════════════════════════════════════
// 5. AgentEvent ------ Agent 的"广播语言"
// ═══════════════════════════════════════════════════════════════
export type AgentEvent =
| { type: "agent_start" }
| { type: "agent_end"; messages: AgentMessage[] }
| { type: "turn_start" }
| { type: "turn_end"; message: AgentMessage; 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: unknown }
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: unknown; partialResult: unknown }
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: ToolResultMessage; isError: boolean };
// ═══════════════════════════════════════════════════════════════
// 6. AgentLoopConfig ------ 循环的"操作手册"
// ═══════════════════════════════════════════════════════════════
export interface AgentLoopConfig {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
model: Model;
convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
toolExecution?: ToolExecutionMode;
shouldStopAfterTurn?: (context: {
message: AssistantMessage;
toolResults: ToolResultMessage[];
context: AgentContext;
}) => boolean | Promise<boolean>;
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
beforeToolCall?: (
context: {
assistantMessage: AssistantMessage;
toolCall: { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> };
args: unknown;
context: AgentContext;
},
signal?: AbortSignal,
) => Promise<{ block?: boolean; reason?: string } | undefined>;
afterToolCall?: (
context: {
assistantMessage: AssistantMessage;
toolCall: { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> };
args: unknown;
result: AgentToolResult;
isError: boolean;
context: AgentContext;
},
signal?: AbortSignal,
) => Promise<{
content?: TextContent[];
details?: unknown;
isError?: boolean;
terminate?: boolean;
} | undefined>;
}
就这 160 行------Agent 层的全部类型骨架。
为什么 Agent 的类型看起来"什么都没有实现"
你可能觉得:写了半天,全是 interface 和 type,没一个能 new 或者 await 的东西。
这是刻意的。
Pi 的 Agent 层设计有一条核心原则:"类型先于实现"(types first)。在写任何一行循环代码之前,先把数据怎么流、事件怎么发想清楚------后面写实现时你会发现,每个函数签名都已经"预言"好了。
就像一个建筑工地在打地基------没盖楼之前,地基看起来就是一片水泥。但它决定了楼能盖多高、柱子放哪里。
🔧 运行验证
类型本身不"运行",但我们可以写一个简单的测试脚本来验证:
src/agent/types.ts的类型能被正确导入- AgentEvent 的 type 字段能收窄
- 声明合并能正常工作
更新 src/index.ts,在文件末尾加上这段测试:
typescript
// ═══════════════════════════════════════════════════════════════
// 第 5 章测试:验证 Agent 类型系统
// ═══════════════════════════════════════════════════════════════
import type {
AgentMessage,
AgentEvent,
AgentTool,
AgentState,
AgentContext,
AgentLoopConfig,
CustomAgentMessages,
} from "./agent/types.js";
// ─── 测试 1:AgentEvent 的类型收窄 ───
// 这段代码不会被真正"调用"(运行时代码被注释了),
// 但只要 TypeScript 编译通过,就说明 discriminated union 设计正确。
function 测试事件类型(event: AgentEvent): string {
switch (event.type) {
case "agent_start":
return "Agent 启动了";
case "agent_end":
return `Agent 结束了,新增 ${event.messages.length} 条消息`;
case "turn_start":
return "新的一轮";
case "turn_end":
return `本轮结束,${event.toolResults.length} 个工具结果`;
case "message_start":
return `消息开始:${event.message.role ?? "未知角色"}`;
case "message_update":
return `消息更新中...`;
case "message_end":
return "消息结束";
case "tool_execution_start":
return `工具 ${event.toolName} 开始执行`;
case "tool_execution_update":
return `工具 ${event.toolName} 执行中...`;
case "tool_execution_end":
return event.isError
? `工具 ${event.toolName} 执行失败`
: `工具 ${event.toolName} 执行成功`;
default:
// 这行如果编译报错,说明上面 switch 漏了事件类型------
// TypeScript 的 exhaustiveness check 在保护你
const _exhaustive: never = event;
return "未知事件";
}
}
// 调用一下让 TypeScript 验证(实际运行时不跑,因为不会真的触发事件)
console.log("事件处理器类型检查通过 ✅");
// ─── 测试 2:声明合并扩展(概念验证)───
// 模拟应用层在另一个文件里通过 declaration merging 添加自定义消息
// 这段代码验证 CustomAgentMessages 的合并机制
// 为了测试,我们在本文件里做一次"声明合并"
declare module "./agent/types.js" {
interface CustomAgentMessages {
/** 测试用的自定义消息------告诉 Agent "该休息了" */
coffeeBreak: { type: "coffeeBreak"; reason: string; timestamp: number };
}
}
// 现在 AgentMessage 应该包含 coffeeBreak 类型
// 如果我们创建一个 coffeeBreak 消息并赋值给 AgentMessage 类型变量...
//
// ⚠️ 注意:下面这段代码是概念演示,不是为了跑通
// 声明合并需要匹配的模块路径和正确的接口定义------当前章节还没实现完整的
// CustomAgentMessages 机制,直接复制粘贴会报 type 错误
// 这个示例的价值在于理解"声明合并"这个设计模式本身
const 咖啡休息: AgentMessage = {
type: "coffeeBreak",
reason: "已经醒了 14 个小时了",
timestamp: Date.now(),
};
// ...TypeScript 不报错,说明声明合并生效了
console.log("声明合并验证通过 ✅");
// ─── 测试 3:打印事件类型列表 ───
// 把 AgentEvent 的所有 type 值列出来------这只是为了让你看清楚
console.log("\n📋 Agent 支持的事件类型:");
console.log(" agent_start --- Agent 启动");
console.log(" agent_end --- Agent 结束");
console.log(" turn_start --- 一轮对话开始");
console.log(" turn_end --- 一轮对话结束");
console.log(" message_start --- 消息开始");
console.log(" message_update --- 消息流式更新");
console.log(" message_end --- 消息完成");
console.log(" tool_execution_start --- 工具开始执行");
console.log(" tool_execution_update --- 工具执行进度");
console.log(" tool_execution_end --- 工具执行完成");
运行验证:
bash
npx tsx src/index.ts
应该看到:
...(前面的 Provider 测试输出)
事件处理器类型检查通过 ✅
声明合并验证通过 ✅
📋 Agent 支持的事件类型:
agent_start --- Agent 启动
agent_end --- Agent 结束
turn_start --- 一轮对话开始
turn_end --- 一轮对话结束
message_start --- 消息开始
message_update --- 消息流式更新
message_end --- 消息完成
tool_execution_start --- 工具开始执行
tool_execution_update --- 工具执行进度
tool_execution_end --- 工具执行完成
再验证 TypeScript 编译无误(这是最重要的):
bash
npx tsc --noEmit
如果没有任何输出------恭喜,类型检查通过。
❓ 常见问题
declaration merging 是什么鬼,能不能不用它
声明合并(declaration merging)是 TypeScript 的特性:同名 interface 会自动合并属性。你可以把它想象成"累加器"------每次声明都往上加属性。
typescript
// 文件 A
interface Box { width: number }
// 文件 B(另一个文件!)
interface Box { height: number }
// TypeScript 眼里:Box = { width: number; height: number }
在 Pi 里,CustomAgentMessages 是一个"空壳接口"。应用层通过 declare module 向它追加自定义消息类型------不需要改动库源码。
能不能不用它? 能。你可以直接写 type AgentMessage = Message | MyCustomType1 | MyCustomType2。但这样库的使用者必须改库源码才能加新类型。声明合并让库保持通用。
AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages] 为啥这么绕
因为 CustomAgentMessages 是一个接口,里面的属性名和类型都可能是未知的。
typescript
// 应用层加了这些东西:
interface CustomAgentMessages {
notification: { type: "notification"; text: string };
artifact: { type: "artifact"; data: string };
}
// keyof CustomAgentMessages = "notification" | "artifact"
// CustomAgentMessages["notification"] = { type: "notification"; text: string }
// CustomAgentMessages["artifact"] = { type: "artifact"; data: string }
// CustomAgentMessages[keyof ...] = { type: "notification"; text: string } | { type: "artifact"; data: string }
// 最终:
// AgentMessage = Message | { type: "notification"; text: string } | { type: "artifact"; data: string }
为什么 AgentEvent 用 | 连了 10 种类型,而不是用一个 interface EventBase { type: string } 继承
因为 discriminated union 的自动类型收窄。如果用继承:
typescript
// 这种写法------TypeScript 在 switch 里不会自动收窄
interface BaseEvent { type: string }
class AgentStart extends BaseEvent { type = "agent_start" }
// switch (event.type): TypeScript 不知道 AgentStart 有哪些专属字段
用联合类型:
typescript
// switch (event.type)------每个 case 里 event 的类型自动收窄
case "tool_execution_end":
// 这里 event.result 有智能提示,因为 TS 知道只有 tool_execution_end
// 才有 result 字段
这是 TypeScript 最强大的类型体操之一------几行代码换来完整的类型安全和 IDE 补全。
AgentState 的属性为啥大部分只读
因为 AgentState 是给外人"看"的,不是给外人"改"的。改状态由 Agent 内部逻辑(第 7 章)通过类方法来操作------否则外面随便一改,Agent 里的状态就脏了,多线程(或者多异步流程)下就是经典的竞态 Bug。
export type ThinkingLevel = "off" | "minimal" | ... 这玩意儿有什么用
约束值范围。如果你写 thinkingLevel: "veryhigh",TypeScript 会直接报错------因为 "veryhigh" 不在联合类型里。这比运行时 if (level !== "off" && level !== "minimal" ...) 强一万倍。
📚 这一章你学到了
| 概念 | 一句话理解 |
|---|---|
AgentMessage |
LLM 消息 + 自定义消息的联合类型------Agent 内部的"综合档案" |
CustomAgentMessages |
类型扩展接口------靠声明合并让应用层在不动库源码的情况下加自定义消息 |
convertToLlm |
翻译官------把 AgentMessage\[\](内部文件)转成 Message\[\](对外邮件) |
AgentTool |
会动手的工具------Tool 说明书 + execute 函数 |
AgentToolResult |
工具执行完交的"答卷"------给 LLM 看的 content + 给 UI 看的 details |
AgentToolUpdateCallback |
工具执行中的"直播推流"------实时推送半成品给 UI |
AgentState |
Agent 的仪表盘------系统提示词、模型、消息历史、流式状态、待执行工具 |
AgentContext |
每次 LLM 调用的"输入包裹"------系统提示词 + 消息列表 + 可用工具 |
AgentEvent |
10 种事件的 discriminated union------Agent 一生中会发出的所有信号 |
AgentLoopConfig |
循环的操作手册------模型、转换器、工具模式、各种钩子 |
ThinkingLevel |
推理深度------从"不想"到"往死里想" |
ToolExecutionMode |
工具执行模式------排队(sequential)还是同时跑(parallel) |
| 声明合并 | TypeScript 的"累加器"------同名 interface 自动合并属性 |
| discriminated union | 一个 ` |
🔗 对应的 Pi 源码
本章的 src/agent/types.ts 是 Pi 源码 packages/agent/src/types.ts 的等比例缩小版。具体对照:
| 本章代码 | Pi 源码 | 说明 |
|---|---|---|
CustomAgentMessages |
packages/agent/src/types.ts:305-307 |
声明合并扩展接口 |
AgentMessage |
packages/agent/src/types.ts:314 |
Agent 内部消息类型 |
AgentTool |
packages/agent/src/types.ts:370-393 |
可执行工具定义 |
AgentToolResult |
packages/agent/src/types.ts:350-360 |
工具执行结果 |
AgentToolUpdateCallback |
packages/agent/src/types.ts:368 |
工具进度回调 |
AgentState |
packages/agent/src/types.ts:322-347 |
公开状态 |
AgentContext |
packages/agent/src/types.ts:397-404 |
循环上下文快照 |
AgentEvent |
packages/agent/src/types.ts:413-428 |
事件联合类型 |
AgentLoopConfig |
packages/agent/src/types.ts:140-282 |
循环配置(本章简化了部分钩子) |
ThinkingLevel |
packages/agent/src/types.ts:289 |
推理级别 |
ToolExecutionMode |
packages/agent/src/types.ts:40 |
执行模式 |
Pi 的 AgentLoopConfig 还包含了 transformContext、getSteeringMessages、getFollowUpMessages 等更高级的钩子------这些我们在第 8 章(消息队列)和第 15 章(上下文压缩)会逐步加上。
下一步
现在 Agent 的"骨骼"搭好了。下一章是最激动人心的部分------写循环。
Agent 循环长这样:
while (没有要停的信号) {
发消息给 LLM → 读它的回复 → 发现它要调工具?
→ 执行工具 → 把结果告诉 LLM → 再来一轮
→ 不调工具了?→ 结束
}
听起来简单?等你亲手写出这个 while 循环,你会第一次真正理解"AI Agent 是怎么思考的"。