当我第一次打开
agent-loop.ts时,脑子里第一个念头是:这也太简单了吧?418 行,没有繁重的抽象,没有依赖注入容器,没有魔法装饰器。但读完整个packages/agent目录之后,我改变了看法------这种"简单"背后藏着极其清晰的工程判断:每一个设计决策都有明确的理由,每一层抽象都恰好到位,不多也不少。这篇文章是我阅读 Pi Monorepo 源码的记录。它是一个用 TypeScript 写的 AI Agent 基础设施项目,核心目标只有一个:让构建可用于生产的 AI Agent 变得可预测。我会从架构全貌讲到最核心的执行循环,试图还原作者在每个设计节点上的思考脉络。
目录
- 项目简介与使用场景
- [与主流 Agent 框架的对比](#与主流 Agent 框架的对比 "#%E4%B8%8E%E4%B8%BB%E6%B5%81-agent-%E6%A1%86%E6%9E%B6%E7%9A%84%E5%AF%B9%E6%AF%94")
- 整体架构设计
- 核心模块深度解析
- [pi-ai:统一 LLM 抽象层](#pi-ai:统一 LLM 抽象层 "#pi-ai%E7%BB%9F%E4%B8%80-llm-%E6%8A%BD%E8%B1%A1%E5%B1%82")
- EventStream:事件流引擎
- [agent-loop:Agent 执行核心](#agent-loop:Agent 执行核心 "#agent-loop-agent-%E6%89%A7%E8%A1%8C%E6%A0%B8%E5%BF%83")
- [Agent 类:高级状态管理封装](#Agent 类:高级状态管理封装 "#agent-%E7%B1%BB%E9%AB%98%E7%BA%A7%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86%E5%B0%81%E8%A3%85")
- Proxy:透明代理支持
- 完整交互流程分析
- 设计亮点总结
项目简介与使用场景
Pi Monorepo 是一个以 TypeScript 编写的工业级 AI Agent 基础设施工具集,托管于 github.com/badlogic/pi...。它不是一个单一的框架,而是一组经过精心设计的可组合包:
| 包名 | 核心职责 |
|---|---|
@mariozechner/pi-ai |
统一多 Provider LLM API(OpenAI、Anthropic、Google、Bedrock 等) |
@mariozechner/pi-agent-core |
Agent 运行时:工具调用、状态管理、事件流 |
@mariozechner/pi-coding-agent |
交互式代码生成 CLI |
@mariozechner/pi-mom |
Slack Bot,将消息委托给编码 Agent 处理 |
@mariozechner/pi-tui |
终端 UI 库(差量渲染) |
@mariozechner/pi-web-ui |
AI 聊天界面 Web 组件 |
@mariozechner/pi-pods |
vLLM GPU Pod 部署管理 CLI |
典型使用场景
场景一:多模型编码助手 开发者需要构建一个可以自由切换 Claude、GPT-4o、Gemini 的编码助手,同时支持工具调用(读文件、执行命令)。pi-ai 提供统一接口,pi-agent-core 处理工具调用循环,pi-coding-agent 则是这一组合的完整实现。
场景二:企业内网代理 企业不想让客户端直接访问 LLM Provider API Key,需要通过内部网关统一鉴权和路由。proxy.ts 中的 streamProxy 函数提供了开箱即用的 SSE 代理流支持。
场景三:Slack 工作流自动化 pi-mom 包监听 Slack 频道消息,触发编码 Agent 执行任务,完成后回复结果------这是典型的长链 Agent 场景。
场景四:vLLM 私有化部署 pi-pods 管理在 GPU Pod 上的 vLLM 实例生命周期,将自托管模型纳入统一的 pi-ai 接口体系。
与主流 Agent 框架的对比
lua
┌─────────────────────────────────────────────────────────────────────┐
│ Agent 框架横向对比 │
├──────────────┬──────────────┬──────────────┬──────────────┬─────────┤
│ │ LangChain │ AutoGen/AG2 │ CrewAI │ pi-mono│
├──────────────┼──────────────┼──────────────┼──────────────┼─────────┤
│ 语言 │ Python/JS │ Python │ Python │TypeScript│
│ Provider抽象 │ ✓ 丰富 │ △ 有限 │ △ 有限 │ ✓ 完整 │
│ 流式输出 │ △ 部分支持 │ ✗ │ ✗ │ ✓ 原生 │
│ 思维链支持 │ △ │ △ │ △ │ ✓ 内置 │
│ 工具调用中断 │ ✗ │ △ │ ✗ │ ✓ 原生 │
│ 类型安全 │ △ │ △ │ △ │ ✓ 严格 │
│ 代理模式 │ △ │ ✗ │ ✗ │ ✓ 内置 │
│ 包体积 │ 极重 │ 重 │ 中 │ 轻量 │
└──────────────┴──────────────┴──────────────┴──────────────┴─────────┘
Pi Mono 相对优势
- 原生流式 + 事件驱动:从最底层的 Provider 响应到最上层的 UI 更新,整个调用链路全程流式,UI 无需轮询。
- Steering / FollowUp 中断机制:用户可以在 Agent 执行工具调用期间注入"方向盘消息",跳过剩余工具调用并立即响应------这在 LangChain 等框架中需要大量定制代码。
- TypeBox 参数验证 :工具参数使用
@sinclair/typebox进行运行时类型验证,错误在执行前被捕获。 - 无 any 类型设计 :整个代码库几乎没有
any,从 Provider 响应到工具结果全部端对端类型安全。 - 轻量内核 :
agent-loop.ts核心逻辑仅 418 行,无隐式依赖魔法。
相对局限
- 生态系统相对年轻,社区工具和预构建工具集比 LangChain 少
- 无内置的 RAG / 向量检索管道(需自行集成)
- 多 Agent 编排(如 AutoGen 的对话图)需自行实现
整体架构设计
scss
┌─────────────────────────────────────────────────────────────────────┐
│ pi-mono 分层架构 │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 应用层 (Application) │ │
│ │ pi-coding-agent CLI pi-mom Slack Bot Web UI / TUI │ │
│ └──────────────────────────────┬──────────────────────────────┘ │
│ │ uses │
│ ┌──────────────────────────────▼──────────────────────────────┐ │
│ │ pi-agent-core (Agent Runtime) │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Agent class │ │ agent-loop │ │ proxy │ │ │
│ │ │ (状态管理封装) │ │ (执行核心) │ │ (代理模式) │ │ │
│ │ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │ │
│ │ └──────────────────┘ │ │
│ └──────────────────────────────┬──────────────────────────────┘ │
│ │ calls │
│ ┌──────────────────────────────▼──────────────────────────────┐ │
│ │ pi-ai (LLM 抽象层) │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ API Registry (提供商注册表) │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────┐ ┌──────────┐ ┌───────┐ ┌────────┐ ┌──────────┐ │ │
│ │ │OpenAI│ │Anthropic │ │Google │ │Bedrock │ │ Mistral │ │ │
│ │ │ │ │ │ │Vertex │ │(lazy) │ │ ...etc │ │ │
│ │ └──────┘ └──────────┘ └───────┘ └────────┘ └──────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
核心设计原则:分层隔离,向下依赖 。应用层只依赖 pi-agent-core,pi-agent-core 只依赖 pi-ai,pi-ai 内部通过注册表模式管理各 Provider,各层职责清晰。
核心模块深度解析
pi-ai:统一 LLM 抽象层
类型系统基础
pi-ai 的核心是一套精心设计的类型体系(packages/ai/src/types.ts):
typescript
// 消息类型:User / Assistant / ToolResult 三元组
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
// Assistant 消息内容块支持文本、思维链、工具调用
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: Api;
provider: Provider;
model: string;
usage: Usage; // 完整的 Token 计量(含 Cache Read/Write)
stopReason: StopReason;
timestamp: number;
}
// 工具调用完整描述
export interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, any>;
thoughtSignature?: string; // Google 特有:复用思维上下文
}
AssistantMessageEvent 定义了一条消息从生成到完成的完整生命周期事件序列:
scss
start → text_start → text_delta(×N) → text_end
→ thinking_start → thinking_delta(×N) → thinking_end
→ toolcall_start → toolcall_delta(×N) → toolcall_end
→ done | error
Provider 注册表模式
scss
┌─────────────────────────────────────────────────────────┐
│ API Registry 设计 │
│ │
│ registerApiProvider({ api, stream, streamSimple }) │
│ │ │
│ ▼ │
│ apiProviderRegistry: Map<string, ApiProvider> │
│ │
│ ┌────────────────┬──────────────────────────────────┐ │
│ │ api key │ provider │ │
│ ├────────────────┼──────────────────────────────────┤ │
│ │"anthropic-... │ { stream, streamSimple } │ │
│ │"openai-comp... │ { stream, streamSimple } │ │
│ │"google-gen... │ { stream, streamSimple } │ │
│ │"bedrock-... │ { lazy stream wrapper } │ │
│ │ ... │ ... │ │
│ └────────────────┴──────────────────────────────────┘ │
│ │
│ stream(model, ctx) → resolveApiProvider(model.api) │
│ → provider.streamSimple(...) │
└─────────────────────────────────────────────────────────┘
stream.ts 中的 streamSimple 是最常用的入口,它从注册表中找到对应 Provider 并调用其 streamSimple 方法:
typescript
// packages/ai/src/stream.ts
export function streamSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const provider = resolveApiProvider(model.api);
return provider.streamSimple(model, context, options);
}
Bedrock 使用了懒加载策略------AWS SDK 体积庞大,只在实际使用时才动态导入:
typescript
function streamBedrockLazy(...): AssistantMessageEventStream {
const outer = new AssistantMessageEventStream();
loadBedrockProviderModule()
.then((module) => {
const inner = module.streamBedrock(model, context, options);
forwardStream(outer, inner); // 转发事件到外部流
})
.catch((error) => {
outer.push({ type: "error", ... });
});
return outer; // 立即返回,事件异步填充
}
EventStream:事件流引擎
EventStream<T, R> 是整个系统的信息传递骨干,实现了基于 AsyncIterator 协议的背压感知事件队列:
typescript
// packages/ai/src/utils/event-stream.ts
export class EventStream<T, R = T> implements AsyncIterable<T> {
private queue: T[] = [];
private waiting: ((value: IteratorResult<T>) => void)[] = [];
private done = false;
private finalResultPromise: Promise<R>;
private resolveFinalResult!: (result: R) => void;
内部状态机
scss
┌─────────────────────────────────────────────────────────────────┐
│ EventStream 状态机 │
│ │
│ push(event) │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ 有等待者? 有等待者? 队列中存储 │
│ (是) (否) │
│ │ │ │
│ 直接唤醒 push to queue │
│ │ │
│ isComplete? │
│ (是) │
│ │ │
│ resolveFinalResult() ←── 终态 │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ AsyncIterator 消费端 │ │
│ │ for await (const event of stream) { │ │
│ │ queue.length > 0 → yield queue.shift() │ │
│ │ done → return │ │
│ │ else → await new Promise(resolve => waiting.push) │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
核心方法详解
背压感知机制 :当消费者处理速度慢于生产者时,queue 数组会累积事件;反之则 waiting 数组累积等待的 Promise。这种设计确保生产者不会无限堆积内存------消费者通过 for await 自然地控制消费节奏。
typescript
// AsyncIterator 实现:消费者 for-await 使用
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: async (): Promise<IteratorResult<T>> => {
// 情况一:消费者落后于生产者 ------ 队列中已有事件
// 场景:LLM Provider 推送事件的速度快于 UI 渲染速度
// 结果:直接返回队列中的事件,无需阻塞
if (this.queue.length > 0) {
return { value: this.queue.shift()!, done: false };
}
// 情况二:流已终止 ------ 不会再有新事件到来
// 场景:LLM 响应完成,所有事件已被消费
// 结果:发出迭代结束信号
if (this.done) {
return { value: undefined as any, done: true };
}
// 情况三:生产者落后于消费者 ------ 暂时没有可用事件
// 场景:消费者(UI)在 LLM 推送事件前调用了 next()
// 或者网络延迟导致事件尚未到达
// 结果:将 resolve 函数注册到等待队列,返回挂起的 Promise
// 当 push() 被调用时,会找到该 resolve 并唤醒它
return new Promise((resolve) => this.waiting.push(resolve));
},
};
}
关键设计 :result() 方法返回 finalResultPromise------消费者可以无需迭代所有事件,直接 await stream.result() 拿到最终结果。这在只需要完整响应、不关心流式过程时非常有用。
kotlin
// 获取最终结果(无需迭代所有事件)
result(): Promise<R> {
// 返回一个 Promise,当流接收到终止事件时完成。
// 该 Promise 在 push() 内部被解析,当 isComplete(event) 返回 true 时:
// if (this.isComplete(event)) {
// this.done = true;
// this.resolveFinalResult(this.extractResult(event));
// }
// 对于 AssistantMessageEventStream,这发生在 "done" 或 "error" 事件时。
return this.finalResultPromise;
}
双返回类型 <T, R> :T 是迭代事件类型,R 是最终结果类型。例如 AssistantMessageEventStream 迭代 AssistantMessageEvent,但 result() 返回 AssistantMessage,避免消费者为获取完整消息而手动收集所有事件。
AssistantMessageEventStream 是专用子类,其终止条件是 done 或 error 事件,结果提取对应的 AssistantMessage:
typescript
export class AssistantMessageEventStream
extends EventStream<AssistantMessageEvent, AssistantMessage> {
constructor() {
super(
(event) => event.type === "done" || event.type === "error",
(event) => {
if (event.type === "done") return event.message;
else if (event.type === "error") return event.error;
throw new Error("Unexpected event type for final result");
},
);
}
}
agent-loop:Agent 执行核心
agent-loop.ts 是整个项目中最精密的机制,418 行代码实现了完整的双层循环 Agent 执行模型。
双层循环架构
scss
┌─────────────────────────────────────────────────────────────────────┐
│ runLoop 双层循环架构 │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 外层循环:处理 follow-up messages(Agent 完成后续新任务) │ │
│ │ │ │
│ │ while (true) { │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ 内层循环:处理工具调用 + steering messages │ │ │
│ │ │ │ │ │
│ │ │ while (hasMoreToolCalls || pendingMessages) { │ │ │
│ │ │ │ │ │
│ │ │ [1] 注入 pendingMessages(用户中途注入) │ │ │
│ │ │ ↓ │ │ │
│ │ │ [2] streamAssistantResponse() ← LLM 调用 │ │ │
│ │ │ ↓ │ │ │
│ │ │ [3] 检查 stopReason(error/aborted → 退出) │ │ │
│ │ │ ↓ │ │ │
│ │ │ [4] executeToolCalls() → 并发执行所有工具 │ │ │
│ │ │ ↓ │ │ │
│ │ │ [5] getSteeringMessages() → 检查中断 │ │ │
│ │ │ } │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ [6] getFollowUpMessages() → 是否有后续任务? │ │
│ │ 有 → 继续外层循环 │ │
│ │ 无 → break,发送 agent_end │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
核心设计模式一览:
| Pattern | Implementation |
|---|---|
| Dual Loop | Outer (follow-up) + Inner (tool + steering) |
| Event-Driven | AsyncIterator-based EventStream<T,R> |
| Lazy Loading | Bedrock provider loaded on first use |
| Type Safety | TypeBox runtime validation, zero any |
| Interruptibility | steer() injects into steeringQueue → skip remaining tools |
| Pluggable Transport | `streamFn: streamSimple |
streamAssistantResponse:LLM 调用与上下文转换
这个函数是 AgentMessage[] 与 Message[] 之间的边界:
typescript
async function streamAssistantResponse(
context: AgentContext,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
stream: EventStream<AgentEvent, AgentMessage[]>,
streamFn?: StreamFn,
): Promise<AssistantMessage> {
// Step 1: transformContext(可选,用于剪枝/注入外部上下文)
let messages = context.messages;
if (config.transformContext) {
messages = await config.transformContext(messages, signal);
}
// Step 2: convertToLlm(过滤 UI 专用消息,转换格式)
const llmMessages = await config.convertToLlm(messages);
// Step 3: 构建 LLM Context
const llmContext: Context = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
// Step 4: 调用 streamSimple,迭代事件流
const response = await streamFunction(config.model, llmContext, {...});
// Step 5: 将 AssistantMessageEvent 转发为 AgentEvent
for await (const event of response) {
switch (event.type) {
case "start":
context.messages.push(partialMessage); // 立即加入上下文
stream.push({ type: "message_start", message: {...partialMessage} });
break;
case "text_delta":
case "thinking_delta":
case "toolcall_delta":
// 更新 partialMessage,发射 message_update
stream.push({ type: "message_update", assistantMessageEvent: event, ... });
break;
case "done":
case "error":
stream.push({ type: "message_end", message: finalMessage });
return finalMessage;
}
}
}
重点:partial message 在 start 事件时就立即加入 context.messages ,后续 delta 事件直接原地更新(context.messages[context.messages.length - 1] = partialMessage),避免了数组频繁追加的开销,也确保了上下文的实时一致性。
executeToolCalls:工具执行与中断检测
ini
┌─────────────────────────────────────────────────────────────────────┐
│ 工具调用执行流程 │
│ │
│ toolCalls = [tool_A, tool_B, tool_C] (串行执行) │
│ │
│ for tool_A: │
│ emit tool_execution_start │
│ validateToolArguments(TypeBox schema) │
│ result = await tool.execute(id, args, signal, onUpdate) │
│ emit tool_execution_end │
│ emit message_start / message_end (ToolResultMessage) │
│ getSteeringMessages() ← 检查用户是否注入了方向盘消息 │
│ │ │
│ ├── 有 steering → 跳过 tool_B, tool_C (skipToolCall) │
│ │ steeringMessages 携带到下一轮 │
│ │ │
│ └── 无 steering → 继续 tool_B │
│ │
│ skipToolCall: │
│ emit tool_execution_start (for skipped tool) │
│ result = "Skipped due to queued user message." │
│ emit tool_execution_end (isError=true) │
│ emit message_start / message_end │
└─────────────────────────────────────────────────────────────────────┘
工具参数验证使用 TypeBox,在运行时执行 JSON Schema 验证:
typescript
const validatedArgs = validateToolArguments(tool, toolCall);
result = await tool.execute(toolCall.id, validatedArgs, signal, (partialResult) => {
// 支持工具执行过程中的流式更新(如 shell 命令的实时输出)
stream.push({ type: "tool_execution_update", partialResult });
});
Agent 类:高级状态管理封装
Agent 类是 agentLoop 的高层封装,维护完整的会话状态并提供响应式事件订阅:
css
┌─────────────────────────────────────────────────────────────────────┐
│ Agent 状态机 │
│ │
│ AgentState { │
│ systemPrompt, model, thinkingLevel │
│ tools: AgentTool[] │
│ messages: AgentMessage[] ← 完整对话历史 │
│ isStreaming: boolean │
│ streamMessage: AgentMessage | null ← 当前流式消息 │
│ pendingToolCalls: Set<string> │
│ error?: string │
│ } │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 消息队列双通道 │ │
│ │ │ │
│ │ steeringQueue ────────────► 中途注入,跳过剩余工具调用 │ │
│ │ followUpQueue ────────────► Agent 完成后注入,触发新一轮 │ │
│ │ │ │
│ │ steeringMode: "all" | "one-at-a-time" │ │
│ │ followUpMode: "all" | "one-at-a-time" │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 用户 API: │
│ agent.prompt("text") → 开始新一轮 │
│ agent.steer(message) → 注入方向盘消息 │
│ agent.followUp(message) → 注入后续消息 │
│ agent.abort() → 中止当前执行 │
│ agent.waitForIdle() → 等待执行完成 │
│ agent.subscribe(fn) → 订阅事件 │
└─────────────────────────────────────────────────────────────────────┘
_runLoop 是实际执行方法,其关键部分是在 for await 循环中将 AgentEvent 映射到内部状态:
typescript
for await (const event of stream) {
switch (event.type) {
case "message_start":
this._state.streamMessage = event.message; // 更新流式消息引用
break;
case "message_update":
this._state.streamMessage = event.message; // 实时更新
break;
case "message_end":
this._state.streamMessage = null;
this.appendMessage(event.message); // 持久化到历史
break;
case "tool_execution_start":
this._state.pendingToolCalls.add(event.toolCallId);
break;
case "tool_execution_end":
this._state.pendingToolCalls.delete(event.toolCallId);
break;
case "agent_end":
this._state.isStreaming = false;
break;
}
this.emit(event); // 转发给所有订阅者
}
Proxy:透明代理支持
streamProxy 让 Agent 可以通过企业内网网关调用 LLM,而不暴露 API Key 给前端。
csharp
┌─────────────────────────────────────────────────────────────────────┐
│ Proxy 流程 │
│ │
│ Client (Browser / App) Proxy Server LLM API │
│ │ │ │ │
│ │ POST /api/stream │ │ │
│ │ {model, context, options} │ │ │
│ │ Authorization: Bearer xxx │ │ │
│ │─────────────────────────────►│ │ │
│ │ │ streamSimple(...) │ │
│ │ │───────────────────►│ │
│ │ │◄── SSE events ─────│ │
│ │ │ │ │
│ │ SSE: ProxyAssistantMsg │ (带宽优化:去除 │ │
│ │ (delta only, no partial) │ partial 字段) │ │
│ │◄─────────────────────────────│ │ │
│ │ │ │ │
│ processProxyEvent() │ │ │
│ → 客户端重建 partial message │ │ │
│ → 推入本地 EventStream │ │ │
└─────────────────────────────────────────────────────────────────────┘
Proxy 事件类型(ProxyAssistantMessageEvent)与直连模式的 AssistantMessageEvent 关键区别:去掉了每个 delta 事件中的 partial 字段 ,因为 partial 随着内容增长会越来越大,在 SSE 传输中会造成显著带宽浪费。客户端的 processProxyEvent 函数在本地重建 partial:
typescript
case "text_delta": {
const content = partial.content[proxyEvent.contentIndex];
if (content?.type === "text") {
content.text += proxyEvent.delta; // 客户端增量拼接
return { type: "text_delta", ..., partial };
}
}
完整交互流程分析
以"用户发送一条消息,Agent 调用工具后回复"为例,从调用 agent.prompt() 到事件到达 UI 订阅者的完整链路:
scss
┌─────────────────────────────────────────────────────────────────────┐
│ 完整调用链路时序图 │
│ │
│ User Code Agent agent-loop │
│ │ │ │ │
│ │ agent.prompt("help") │ │ │
│ │──────────────────────►│ │ │
│ │ │ _runLoop(msgs) │ │
│ │ │───────────────────►│ │
│ │ │ │ agentLoop() │
│ │ │ │ push agent_start│
│ │ │ │ push turn_start │
│ │◄── event: agent_start ─────────────────────│ │
│ │◄── event: turn_start ─────────────────────│ │
│ │◄── event: message_start(user) ─────────────│ │
│ │◄── event: message_end(user) ───────────────│ │
│ │ │ │ │
│ │ │ streamAssistantResponse() │
│ │ │ │ streamSimple() │
│ │ │ │ ← pi-ai 调用 │
│ │ │ │ │
│ │ │ AssistantMessageEvent stream │
│ │ │ │◄── start │
│ │◄── message_start ──────────────────────────│ │
│ │ │ │◄── text_delta │
│ │◄── message_update ─────────────────────────│ (流式文字) │
│ │ │ │◄── toolcall_start│
│ │◄── message_update ─────────────────────────│ │
│ │ │ │◄── done │
│ │◄── message_end ────────────────────────────│ │
│ │ │ │ │
│ │ │ executeToolCalls() │
│ │◄── tool_execution_start ───────────────────│ │
│ │ │ │ tool.execute() │
│ │◄── tool_execution_update ──────────────────│ (进度流) │
│ │◄── tool_execution_end ─────────────────────│ │
│ │◄── message_start(toolResult) ──────────────│ │
│ │◄── message_end(toolResult) ────────────────│ │
│ │ │ │ │
│ │ │ (再次 streamAssistantResponse)
│ │◄── turn_end ───────────────────────────────│ │
│ │◄── turn_start ─────────────────────────────│ │
│ │ ... (第二轮 LLM 调用) ... │ │
│ │◄── agent_end ──────────────────────────────│ │
│ │ │◄── stream.result() │ │
│ │ │ appendMessage(...) │ │
│ │ │ isStreaming = false │ │
└─────────────────────────────────────────────────────────────────────┘
AgentMessage 与 LLM Message 的双轨并行
scss
┌─────────────────────────────────────────────────────────────────────┐
│ 双轨消息体系:内部视图 vs LLM 视图 │
│ │
│ context.messages (AgentMessage[]) llmMessages (Message[]) │
│ ┌─────────────────────────┐ ┌────────────────────────┐ │
│ │ UserMessage │ ─────── │ UserMessage │ │
│ │ AssistantMessage │ ─────── │ AssistantMessage │ │
│ │ ToolResultMessage │ ─────── │ ToolResultMessage │ │
│ │ NotificationMessage │ ─ 过滤 ─│ (不传给 LLM) │ │
│ │ ArtifactMessage │ ─ 过滤 ─│ (不传给 LLM) │ │
│ └─────────────────────────┘ └────────────────────────┘ │
│ │ │
│ convertToLlm() 边界 │
│ (每次 LLM 调用前执行) │
└─────────────────────────────────────────────────────────────────────┘
通过 TypeScript 的 Declaration Merging,应用层可以透明地扩展 AgentMessage 类型而无需修改核心库:
typescript
// 应用代码中
declare module "@mariozechner/pi-agent-core" {
interface CustomAgentMessages {
artifact: ArtifactMessage;
notification: NotificationMessage;
}
}
// AgentMessage 自动变为 Message | ArtifactMessage | NotificationMessage
设计亮点总结
1. 端到端流式架构
arduino
LLM Provider SSE → AssistantMessageEventStream
→ AgentEvent (agent-loop)
→ subscriber callbacks (Agent class)
→ UI re-render
没有中间 Promise 打断流,整个链路零轮询。
2. Steering 中断机制
scss
用户输入 "停!改一下方向"
│
▼ agent.steer(message)
└─► steeringQueue.push(message)
│
▼ (当前工具执行完毕后)
getSteeringMessages()
│
▼ skipToolCall × N (跳过剩余工具)
│
▼ 下一轮 LLM 调用携带 steering message
这使得 Agent 在执行长耗时工具链时可以被人类"掌舵",避免了"失控执行"问题。
3. 上下文管道
scss
AgentMessage[]
→ transformContext() (剪枝、注入外部知识)
→ convertToLlm() (格式转换、过滤 UI 消息)
→ Message[]
→ LLM Provider
两步管道清晰分离了"业务级消息变换"与"格式级消息转换"。
4. 声明式工具定义
typescript
const readFileTool: AgentTool<typeof ReadFileParams> = {
name: "read_file",
description: "Read file contents",
label: "Reading file",
parameters: ReadFileParams, // TypeBox schema
execute: async (id, { path }, signal, onUpdate) => {
// TypeBox 在 executeToolCalls 中已验证 args 类型
const content = await fs.readFile(path, "utf-8");
return {
content: [{ type: "text", text: content }],
details: { path, size: content.length },
};
},
};
工具定义、参数验证、UI 标签、执行逻辑全部内聚在单一对象中。
5. 可替换的流函数
typescript
// 直连模式(默认)
const agent = new Agent({ streamFn: streamSimple });
// 代理模式(企业内网)
const agent = new Agent({
streamFn: (model, context, options) =>
streamProxy(model, context, {
...options,
authToken: await getToken(),
proxyUrl: "https://internal-gateway.corp",
}),
});
streamFn 是一个普通函数类型,无需继承或实现接口,完全符合开闭原则。
读完之后
读这份代码库,我反复被一件事打动:它在任何地方都没有做"多余的事"。
EventStream 没有提供 map、filter、merge 这些响应式操作符,因为 for await 已经够用。agent-loop.ts 没有实现"多 Agent 协作",因为那是上层应用该解决的问题。AgentTool 没有注册中心,因为一个普通数组已经足够。
这种克制在当今 AI 框架领域很罕见。LangChain 在构建之初就试图覆盖所有场景,结果是一个庞大的抽象层迷宫,初学者难以入门,高级用户又深陷配置地狱。Pi Monorepo 走了一条相反的路:只构建可以被清晰推理的核心,把"更多功能"的空间留给使用者。
当然,这也意味着它目前还不是一个"开箱即用"的完整框架------没有内置的 RAG 管道、没有向量数据库集成、没有可视化调试工具。但对于一个真正理解自己在构建什么的团队来说,这恰恰是优点:你能看清楚每一行代码在做什么,你能在出问题时准确定位,你不会在某个隐藏的中间件里迷失。
如果你正在用 TypeScript 构建一个需要长期维护的 AI Agent 系统,这份代码库值得认真读一遍。不只是为了用它,也为了学习这种"恰到好处"的工程风格。