GitHub周趋势2026W25 | Headroom 压缩 95% Token、NVIDIA 开源 AI Agent 安全扫描器、…
2026-07-28
2026-07-28 0
先讲一下背景。Pi 是 Mario Zechner(没错,就是 libgdx 那个 Mario)做的 terminal coding agent。它最近很火,因为它是 OpenClaw 背后的底层 harness——那个在 Hacker News 上火了一整天的"AI 自己写代码改自己的代码改到停不下来"的项目。

但 Pi 本身其实很克制。它不是一个"什么都帮你做好的框架",而是一套可以让你自己组装 agent 的积木。
整个项目是一个 TypeScript monorepo,核心包只有 4 个:
┌──────────────────────────────────┐│pi-coding-agent (CLI 工具层) │46,859 行├──────────────────────────────────┤│pi-tui (终端 UI 库)│11,228 行├──────────────────────────────────┤│pi-agent-core (agent 运行时) │ 7,964 行├──────────────────────────────────┤│pi-ai (统一 LLM API) │30,157 行 (含模型数据)└──────────────────────────────────┘
注意虽然总行数看起来很多,但 pi-ai 有 16,400 行是自动生成的模型数据列表。真正的核心逻辑其实相当精简。
最让人意外的是:Pi 的 coding agent 默认只有 4 个内置工具——read、write、edit、bash。再加上可选的 grep、find、ls。没了。
没有 MCP,没有 sub-agent,没有 plan mode,没有 permission popup。这些都不是"还没做",而是故意的。Mario 说得很直白:"如果你需要这些,自己去写 extension。"
这种设计哲学让 Pi 的源码变得非常值得学习——因为它小到能读懂,但又完整到能跑起来。
先从最底层开始看。pi-ai 要解决的问题很简单:让上层代码不用关心背后用的是 Anthropic 还是 OpenAI 还是 Google。
核心是 api-registry.ts 里的一个 Map:
const apiProviderRegistry = new Map<string, RegisteredApiProvider>();function registerApiProvider(provider: RegisteredApiProvider, sourceId?: string) {apiProviderRegistry.set(provider.api, provider);}function getApiProvider(api: string): RegisteredApiProvider | undefined {return apiProviderRegistry.get(piApi);}
就这么简单。每个 provider 通过一个 Api 标识符注册(比如 "anthropic-messages"、"openai-completions"),然后上层通过这个标识符查找。
每个注册的 provider 必须暴露两个函数:stream 和 streamSimple,签名完全一致:
type StreamFunction<TApi, TOptions> = (model: Model
这意味着只要你实现了这个接口,你的 provider 就能无缝接入 pi 的整个生态。这是整个 pi-ai 最核心的抽象。
我翻了几个 provider 的实现,发现它们的结构高度一致,像是照着模板写的:
1. 创建 AssistantMessageEventStream2. 创建 provider 特定的 SDK client(Anthropic SDK / OpenAI SDK / Google genai SDK...)3. 把统一的 Context(UserMessage / AssistantMessage / ToolResultMessage)转成 provider 特定的请求格式4. 调 API5. 把 provider 特定的流事件转成统一的 AssistantMessageEvent6. push 到 EventStream
拿 Anthropic 的 provider 举个例子,它大概长这样:
export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions> = (model, context, options?): AssistantMessageEventStream => {const stream = new AssistantMessageEventStream();(async () => {const client = new Anthropic({ apiKey, baseURL: model.baseUrl });const params = buildAnthropicParams(model, context, options);const response = await client.messages.stream(params);// 把 Anthropic 的事件转成统一事件for await (const event of response) {switch (event.type) {case 'content_block_delta':stream.push({ type: 'text_delta', delta: event.delta.text, contentIndex });break;// ...}}})();return stream;};
跨 provider 最难搞的是啥? 我看了 transform-messages.ts 才知道——是 tool call ID 的格式差异。OpenAI 的 tool call ID 可以很长(~450 字符),但 Anthropic 只接受 64 字符以内。如果你先调了 OpenAI 拿到 tool result,再切到 Anthropic 继续对话,tool call ID 就会被截断。pi-ai 会在消息转换时自动做这些兼容处理。
pi-ai 定义了一套 LLM 流式响应的事件协议,所有 provider 都遵守:
start → (text_start / text_delta / text_end)* (thinking_start / thinking_delta / thinking_end)* (toolcall_start / toolcall_delta / toolcall_end)* → done | error
这套协议的实现是 EventStream 类,只有 88 行:
class EventStream
它既可以被 for await...of 拉取式消费,也可以被 push 式消费,两端都支持。
thinking 事件这里要多说一句。各个 provider 对"推理过程"的处理完全不同:
thinking / signature 块reasoning_effort 参数,结果在 content 里thinking 块pi-ai 统一成 thinking_start / thinking_delta / thinking_end 事件,上层完全不用关心底层差异。
models.generated.ts 有 16,400 行,全是结构化的模型数据。每个模型长这样:
{id: "claude-sonnet-4-6",name: "Claude Sonnet 4.6",api: "anthropic-messages",provider: "anthropic",baseUrl: "https://api.anthropic.com/v1",reasoning: true,cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },contextWindow: 200000,maxTokens: 8192,compat: { /* provider 特定的兼容覆盖 */ }}
注意 compat 字段——它处理的是那些"看起来兼容 OpenAI API 但其实有些差异"的第三方服务。比如有些服务不支持 developer role,有些用不同的 max_tokens 字段名,这些差异都记录在模型数据里,而不是写死在 provider 逻辑中。
pi-agent-core 是在 pi-ai 上面加了一层:状态管理 + tool 执行编排 + 事件系统。
agent.ts(557 行)定义了一个 Agent 类,它包装了底层的 agent loop,提供:
class Agent {state: AgentState // 可读写状态prompt(message) // 发起新对话continue()// 从当前上下文继续steer(message)// 在当前 turn 完成后插入消息followUp(message) // 在 agent 停止前插入消息subscribe() // 注册事件监听器abort() // 终止当前 runwaitForIdle() // 等当前 run 结束}
Agent 的状态管理有个小细节——所有数组赋值都会 slice():
set messages(msgs: AgentMessage[]) {this.#state.messages = msgs.slice();// 防外部突变}
这避免了上层代码不小心修改了 agent 内部状态的引用。
Agent 的事件流覆盖了从开始到结束的每一个步骤:
agent_start└── turn_start ├── message_start / message_end(用户消息) ├── message_start → message_update × N → message_end(assistant 流式响应) ├── tool_execution_start → tool_execution_update × N → tool_execution_end ├── message_start / message_end(tool result) └── turn_end└──(检查 steering queue → 如果有,回到 turn_start)agent_end
这套事件系统不是用 EventEmitter 实现的,而是监听器模式——Agent 维护一个监听器列表,事件来的时候逐个调用:
async processEvents(event: AgentEvent) {// 先更新内部状态this.#updateStateFromEvent(event);// 再通知监听器for (const listener of this.#listeners) {await listener(event);}}
关键区别:监听器是 await 的,按注册顺序执行。这保证了事件的有序性——前面的监听器处理完了,后面的才会收到。
当 LLM 返回 tool call 时,Agent 要执行这些 tool。这里的设计很有意思:
两种执行模式:
// 并行模式的 Phase 2const results = await Promise.all(preparedCalls.map(call => executePreparedToolCall(call)));
注意这里有个重要的 edge case:tool 执行顺序和 tool result 消息的顺序是不同的。tool result 消息必须按 assistant 原始请求的顺序排列,否则 LLM 会搞混。所以并行模式下,即使 tool B 比 tool A 先完成,它在消息列表中的位置仍然在 A 之后。
每个 tool 执行有 4 个步骤:
prepareToolCall()1. 按 name 查找 tool2. prepareArguments() — 参数兼容性转换3. validateToolArguments() — TypeBox schema 校验4. beforeToolCall Hook — 可 block 执行→ 失败则立即返回错误结果executePreparedToolCall()→ 实际调 tool.execute()finalizeExecutedToolCall()→ afterToolCall Hook — 可覆盖 content / isErroremit 事件
这套流程有个巧妙的屏障语义:当 assistant 的 message_end 事件发出后,所有事件监听器都处理完了,才会进入 tool 执行的 preflight。这意味着在 beforeToolCall hook 里看到的 agent state 已经包含了完整的 assistant 消息。
这个机制解决了"agent 正在跑的时候用户想插话"的问题:
用户输入 steer("等一下,先看看这个文件")→ 消息进入 steeringQueue→ 当前 tool 执行完成后,注入这条消息→ LLM 在新的 turn 里处理它
用户输入 followUp("做完之后记得提交 commit")→ 消息进入 followUpQueue→ agent 完成所有 tool 调用,准备结束→ 检查 followUpQueue,发现有消息,继续一轮→ 处理完 follow-up 消息,真的结束
Steering 是"我现在就要说",follow-up 是"你先忙完再处理这个"。区别在于插入的时机不同。
底层 agent loop 的核心循环逻辑大概是这样:
outer loop:while true: // inner loop: tool-call loopturn_startstreamAssistantResponse()// LLM 调用if error/aborted → breakexecuteToolCalls() // tool 执行turn_endif shouldStopAfterTurn → breakgetSteeringMessages() → 有就继续 inner loopgetFollowUpMessages() → 有就继续 outer loopagent_end
这层我觉得是最酷的部分之一。Mario 没有用现成的终端 UI 框架,而是自己写了一个极简的 TUI 库。
每个组件只有一个接口:
interface Component {render(width: number): string[];// 对给定宽度渲染成字符串数组handleInput?(data: string): void;invalidate(): void; // 清除缓存}
没错,没有虚拟 DOM,没有 diff 算法。render() 就是一个纯函数,输入宽度,输出行数组。
这跟 React 的 reconcile 思路完全不同。React 是"声明式→虚拟 DOM→diff→patch 真实 DOM",而这里的做法是"每次重新渲染整棵组件树,然后和上一次的结果做行级比较"。
渲染管道在 tui.ts 的 doRender() 里:
1. 递归调用所有子组件的 render(width) → 得到 newLines[]2. 合成 overlay 图层3. 逐行对比 previousLines[i] !== newLines[i]4. 找到第一个不同的行和最后一个不同的行5. 只发送 changed 区域给终端
核心就是字符串的引用比较——previousLines[i] !== newLines[i]。由于组件会缓存渲染结果(文本没变就不重新渲染),这个比较非常快。
渲染有节流控制:16ms 最小间隔,跟 60fps 对齐。
有个很赞的细节:Mario 用了 DEC 2026 同步输出 转义序列包裹整个刷新区域,防止终端在渲染过程中闪烁。
组件怎么告诉终端"光标应该放在这里"?Pi TUI 用了一个很巧妙的方法——在渲染输出中嵌入一个特殊的 APC 序列作为标记:
const CURSOR_MARKER = 'x1b_CURSORx1b\';// 编辑器在渲染时嵌入光标位置render(width) {lines[cursorRow] = text + CURSOR_MARKER;return lines;}// TUI 在渲染后提取光标位置function extractCursorPosition(lines: string[]): { row: number, col: number } {for (let i = 0; i < lines.length; i++) {const idx = lines[i].indexOf(CURSOR_MARKER);if (idx >= 0) {lines[i] = lines[i].replace(CURSOR_MARKER, '');return { row: i, col: idx };}}}
这样 组件就完全不用关心"光标怎么移动"这件事。它只需要在渲染时标记"光标应该在这里",TUI 引擎会在渲染完成后统一把硬件光标移过去。组件和光标管理完全解耦。
终端输入是流式的,一个转义序列可能被拆成多个 chunk 到达。StdinBuffer 处理这个问题:
stdin chunk 1: "x1b[200~"(bracketed paste 开始)stdin chunk 2: "hello wor"stdin chunk 3: "ldx1b[201~"(bracketed paste 结束)StdinBuffer 不会立即吐出部分序列,而是等一段时间(超时驱动),确认序列完整后再交给键盘解析器。
最上层就是把前面三层组装起来,加入具体的工具实现和会话管理。
main.ts 的启动流程非常清晰:
1. 参数解析 (含离线模式/诊断)2. resolveAppMode() → interactive / print / json / rpc3. 运行会话数据迁移4. createSessionManager() → 新建/恢复/继续/分支会话5. 创建 AgentSessionServices (模型注册表+设置+资源加载器+扩展)6. 分发到对应模式
每个工具都遵循统一的 ToolDefinition 接口:
interface ToolDefinition<TInput, TDetails> {name: string;label: string;description: string; // 给 LLM 看parameters: TSchema; // TypeBox schemaexecutionMode: ToolExecutionMode;execute(params, signal, onUpdate, ctx) → Promise<ToolResult>;}
read 工具(363 行):可插拔的 ReadOperations(支持本地和 SSH),自动检测图片 MIME 并缩放,行范围读取,语法高亮,输出截断(默认 200 行 / 50KB)。
write 工具(281 行):自动创建父目录,文件变更队列去重(同一个文件的多次写入会合并),语法高亮预览。
edit 工具(493 行):核心是 SEARCH/REPLACE 模式,使用 edit-diff.ts(454 行)的 diff 引擎计算编辑距离,生成统一 diff。同一个路径的变更通过 FileMutationQueue 去重。
bash 工具(447 行):child_process.spawn() 执行,流式输出 stdout/stderr,AbortSignal 超时控制,进程树终止,输出截断。
每个工具都实现了 BashOperations / ReadOperations 这样的策略接口,使得远程执行(SSH)只需要提供不同的策略实现即可,工具本身的逻辑完全不变。
扩展系统的设计类似 VS Code API——通过事件钩子 + 上下文对象来扩展:
扩展可以做的事情:1. 添加工具(addTool)2. 添加命令(addCommand)3. 添加快捷键(addKeybinding)4. 添加上下文(addContext)→ 注入到 LLM 的 system prompt5. 添加渲染器(addRenderer)→ 自定义消息在 TUI 中的显示6. 监听生命周期事件(sessionStart, turnStart, agentStart...)
扩展加载器用 jiti(开发时)或虚拟模块(Bun 二进制)加载 TypeScript 扩展,支持从 ~/.pi/agent/extensions/、项目目录 .pi/extensions/ 和 npm 包发现。
Pi 的会话不是线性的,而是树结构。每个会话文件是一个 JSONL 文件:
{"type":"session","id":"s1","version":3,"cwd":"/project","timestamp":"..."}{"type":"message","id":"m1","parentId":null,"message":{...}}{"type":"message","id":"m2","parentId":"m1","message":{...}}{"type":"message","id":"m3","parentId":"m1","message":{...}}← 分支点!
id 和 parentId 形成了一棵树。这意味着你可以:
tree 视图中查看所有分支会话管理器(session-manager.ts,1458 行)构建了一个 byId 索引,支持 O(1) 的节点查找。getTree() 遍历构建整棵树,buildSessionContext(leafId) 从叶子走到根,收集消息、模型变更、压缩摘要。
看到这里,你可能会想:这么多代码,从哪里开始啃比较好?
我建议的阅读路线:
先读 pi-agent-core 的 types.ts(418 行)。理解了 AgentMessage、AgentTool、AgentEvent 这些核心类型,你就能把握整个系统的数据结构设计。这是最"薄"但也最关键的一层。
读 agent-loop.ts(742 行)的 runLoop() 函数。这是整个 Pi 的大脑——理解了它,你就理解了"LLM 调用→tool 执行→继续循环"这个最基本的 agent 模式。
读一个 provider 的实现,比如 openai-completions.ts 或 anthropic.ts。不需要读完所有 provider,读一个你就明白了"如何把不同 API 抽象成统一接口"这个模式。
读 read.ts 或 bash.ts。看看一个"LLM 可以调用的 tool"在代码层面到底是什么样——参数定义、执行逻辑、结果返回。
读 tui.ts 的 doRender() 方法。差分渲染在终端中的实现思路,可以迁移到很多其他地方。
读 types.ts + runner.ts。Pi 的扩展 API 设计得很像 VS Code,如果你以后想做 agent 框架的插件系统,这部分很有参考价值。
总的来说,Pi 是一个用最少的抽象做最多的事的项目。它没有用任何 fancy 的设计模式,没有过度工程化,每一层解决一个具体的问题,然后通过清晰的接口组合起来。
整个项目 TypeScript + MIT 授权,52k stars,代码质量很高(有完整的 CI、锁文件完整性检查、husky pre-commit hook)。想学 agent harness 怎么写的朋友,Pi 真的是眼下最好的学习材料之一。
参考链接: