【pi-mono 源码解析 1/4】深入 pi-mono Agent Loop:一个 TypeScript AI 代理的上下文管理哲学
从 agent-loop.ts 的双层 while 到 compaction.ts 的增量摘要,逐行解读 pi-mono 如何在有限的上下文窗口里维持一个 AI 代理的"长期记忆"——以及这个设计在生产环境中隐藏的 8 个工程缺口。
第 1 篇 / 共 5 篇
pi-mono 1 是 Mario Zechner 开源的 TypeScript AI 代理框架,结构简洁但工程密度极高。本文将深入其核心源码,逐层解读 Agent 循环、并行工具执行、上下文压缩三个关键机制,并从设计决策和工程质量两个维度给出评估。
源码已克隆至本地分析,所有行号引用均经过实测验证。
一、为什么 Agent 需要"记忆管理"
一个朴素的 LLM 调用是无状态的:给一段上下文,拿回一段回复,完成。
Agent 的问题在于它需要多轮对话。工具调用结果要喂给下一轮,用户反馈要追加,助手的中间思考要保留——这些东西叠加起来,很快就会超出模型的上下文窗口(通常 128K–200K token)。
更微妙的是:模型的有效注意力并不均匀分布在整个上下文里。早期轮次的信息会被稀释,"LLM 忘事"的本质不是上下文超长,而是信息密度过低时关键细节丢失在噪音中。
pi-mono 对这个问题的回答是一个完整的上下文生命周期管理系统,核心分三层:
- Agent 循环(
agent-loop.ts):控制会话的推进节奏 - 并行工具执行(
agent-loop.ts:451-515):在正确的工具执行语义下追求效率 - 上下文压缩(
harness/compaction/compaction.ts):在超出窗口前主动归纳,保留信息密度
这三层环环相扣。理解任何一层,都需要知道另外两层在做什么。
二、双层 While:Agent 的生命周期状态机
整个 Agent 的运行逻辑由 runLoop() 函数(agent-loop.ts:155)控制,结构是一个双层 while 循环:
// agent-loop.ts:155 — runLoop() 外层循环
while (true) { // 外层:等待后续对话轮次
// ... 推进一轮对话
while (hasMoreToolCalls || pendingMessages.length > 0) { // 内层:处理工具调用批次
// ... 执行工具,处理结果
}
}
四条终止路径
外层循环有四条明确的退出路径,分别对应不同的终止语义:
| 路径 | 触发条件 | 位置 |
|---|---|---|
| 错误/中断 | stopReason === "error" | "aborted" | line 196–199 |
| 钩子停止 | shouldStopAfterTurn?.() 返回 true | line 249 |
| 无后续消息 | getSteeringMessages?.() 返回空 | line 265 |
| 工具全终止 | terminate: true 批次全员 | line 210 |
第四条值得细说。shouldTerminateToolBatch(agent-loop.ts:544)使用的是 every():
finalizedCalls.every(c => c.result.terminate === true)
这是全票通过语义,不是任意一个工具返回 terminate 就停。原因是并行执行时一批工具同时运行,只有全部表态"结束"才能停止循环——这是并行设计带来的语义约束,串行框架不需要这么复杂。
per-turn 动态模型切换
prepareNextTurn 钩子(agent-loop.ts:226-239)允许每轮对话前动态切换模型:
if (config.prepareNextTurn) {
const update = await config.prepareNextTurn(state, signal);
if (update?.model) currentModel = update.model;
}
这支持"便宜模型做工具调用、贵模型做最终回答"的成本优化模式,且不需要重启整个 Agent session。
WARNING
已确认的无界风险
runLoop() 的外层 while(true)(line 155)没有任何 max_iterations 计数器。当 beforeToolCall 钩子持续返回 block 时,被阻止的工具会生成 error ToolResult 返回给 LLM,LLM 可能再次调用同一工具——形成确定性无限循环,不依赖 LLM 行为随机性。全文零处有 max_iterations 检测。
三、并行工具执行:预分配槽位保证有序性
批次降级机制
工具可以在 Schema 中声明自己的执行模式:"sequential" 或 "parallel"(默认)。executeToolCalls() 的分派逻辑(agent-loop.ts:373-388)使用 some() 检测:
const hasSequentialToolCall = toolCalls.some(
tc => tools[tc.name]?.executionMode === "sequential"
);
一个工具声明 sequential,整批都降级串行。
这个保守选择的原因是:bash 命令和文件写入混在同一批次时,强制串行可以避免竞争条件。代价是"只读工具"被一个"写工具"拖慢,这是 pi-mono P1 级别的性能债务之一。
预分配槽位:保序的数据结构秘密
并行执行路径(agent-loop.ts:451-515)最精妙的设计是预分配槽位:
// agent-loop.ts:478 — 按源顺序预分配槽位
const finalizedCalls: Array<FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>)> =
toolCalls.map(tc => () => executeOneTool(tc)); // 每个工具对应固定位置的 thunk
// agent-loop.ts:502 — Promise.all 统一解决
const orderedFinalizedCalls = await Promise.all(
finalizedCalls.map(entry => typeof entry === "function" ? entry() : entry)
);
保序的关键不在于 Promise.all 的返回顺序(那只是数组索引顺序),而在于 finalizedCalls 在并发启动前就按源顺序分配了槽位。工具 B 先完成也只能填自己的槽,不会重排工具 A 的位置。
这是顺序语义编码在数据结构里的典型案例:不依赖 Promise 的 resolve 顺序(那是语言行为),而是依赖预分配数组的索引语义(那是显式的业务决策)。
两条时间轴
并行执行场景下存在两条语义不同的时间轴:
tool_execution_end事件:按完成顺序触发(B 先完成先触发)message_end事件(ToolResultMessage):按源顺序提交(A 始终在 B 前面)
这不是 bug,是设计。Anthropic API 要求 tool_use 和 tool_result 配对且有序,所以提交顺序必须严格保持源顺序;而执行完成顺序是并发的自然结果,暴露给 UI 消费者做实时进度展示。
四、上下文压缩三幕剧
压缩系统的核心在 harness/compaction/compaction.ts(888 行),分三个关键函数:
第一幕:何时压缩 — shouldCompact()
// compaction.ts:195-198
function shouldCompact(contextTokens: number, settings: CompactionSettings, model: Model): boolean {
return contextTokens > model.contextWindow - settings.reserveTokens;
}
默认配置(compaction.ts:111):reserveTokens = 16384,keepRecentTokens = 20000。
当已用 token 超过 contextWindow - 16384 时触发压缩。这留出 16K token 作为新响应的生成空间。
NOTE
Token 估算的精度问题
Token 计数使用 char / 4 启发式(types.ts:219-258),对 ASCII 文本是合理近似,但对中文等 CJK 字符会严重低估(实际消耗约为估算值的 3 倍),导致压缩触发时机偏晚。这是 P2 级债务,修复需要引入真正的 tokenizer(成本较高)。
第二幕:在哪里切 — findCutPoint()
findCutPoint()(compaction.ts:328-376)是整个系统的算法核心,三个 Pass 完成 O(n) 切割点选择:
Pass 1(line 334):前向线性扫描,收集所有有效切割点
有效切割点类型:user、assistant、branchSummary、compactionSummary、custom 等。
无效:toolResult。工具结果必须紧跟其 assistant 消息(含 toolCall),单独留下 toolResult 会破坏 LLM 上下文结构。
Pass 2(line 339-355):从末尾反向扫描,累积 token 直到达到 keepRecentTokens(默认 20000),找到"保留区"的起始位置,然后在有效切割点数组里做前向线性扫描找第一个 >= i 的点:
for (let i = endIndex - 1; i >= startIndex; i--) {
accumulatedTokens += estimateTokens(entry.message);
if (accumulatedTokens >= keepRecentTokens) {
for (let c = 0; c < cutPoints.length; c++) {
if (cutPoints[c] >= i) { cutIndex = cutPoints[c]; break; }
}
break;
}
}
TIP
注意
内层查找是线性扫描,不是二分搜索——cutPoints 数组没有 lo/hi 跳转。由于外层触发 break 后内层最多执行一次,总复杂度仍是 O(n + k),结论不变。
Pass 3(line 357-366):回退调整,跳过 label/metadata 等非消息条目,确保切割点落在语义边界上。
最终输出 CutPointResult:
interface CutPointResult {
firstKeptEntryIndex: number; // 切割位置
turnStartIndex: number; // -1 = 不分割 turn
isSplitTurn: boolean; // 驱动后续 compact() 走哪条路径
}
isSplitTurn 是三个 Pass 计算的核心输出——它是一个状态机守卫值,将算法复杂度完全封装在数据结构里。
第三幕:怎么归纳 — generateSummary() 的增量聚合
generateSummary()(compaction.ts:455-517)实现了Incremental Aggregation模式:
// compaction.ts:469-479
const maxTokens = Math.min(Math.floor(0.8 * reserveTokens), model.maxTokens);
let basePrompt = previousSummary
? UPDATE_SUMMARIZATION_PROMPT // 增量更新路径
: SUMMARIZATION_PROMPT; // 全量生成路径
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
if (previousSummary) {
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
}
增量路径:<conversation> 里只包含自上次切割点以来的新增消息,<previous-summary> 里是上次摘要全文。UPDATE_SUMMARIZATION_PROMPT(compaction.ts:415-428)的核心指令:PRESERVE all existing information + ADD new progress, decisions, and context,强制分段输出 ## Goal / ## Progress / ## Next Steps。
这让每次 compaction 的 LLM 输入量维持在 O(delta),而不是随对话增长线性增加的 O(n)。随着会话轮次增加,每次压缩的成本趋于稳定,不成为新的瓶颈。
区别于 Memoization(相同输入跳过重算):这里每次输入都不同(新增了消息),没有缓存命中——它是运行态摘要的增量追加,不是缓存复用。
Split-turn 的 Fork-Join
当 findCutPoint() 的切割点恰好落在一个未完成的 turn 中间(isSplitTurn: true),compact()(compaction.ts:625-693)会并发生成两份摘要:
// compaction.ts:651 — Fork-Join 并发
const [historyResult, turnPrefixResult] = await Promise.all([
generateSummary(messagesToSummarize, ...), // 历史摘要,budget: 0.8 * reserveTokens
generateTurnPrefixSummary(turnPrefixMessages, ...), // turn 前缀摘要,budget: 0.5 * reserveTokens
]);
两个摘要语义完全解耦,没有数据依赖,并发安全。0.5 vs 0.8 的 budget 差异反映了语义权重:turn prefix 只是"为了理解保留后缀提供的背景",历史摘要需要覆盖更多信息。
五、设计哲学:类型系统的边界在哪里
ADT 穷举 vs 声明合并扩展
pi-mono 在同一个包里同时使用了两种截然相反的类型策略:
策略 A — ADT(代数数据类型)穷举:SessionTreeEntry 是 11-variant 联合类型(harness/types.ts:409),所有处理点用 switch exhaustive check,编译时保证每个变体都被处理:
type SessionTreeEntry = MessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry
| ActiveToolsChangeEntry | CompactionEntry | BranchSummaryEntry | CustomEntry
| CustomMessageEntry | LabelEntry | SessionInfoEntry | LeafEntry;
策略 B — 声明合并开放扩展:CustomAgentMessages(types.ts:305-307)是一个空接口:
export interface CustomAgentMessages {}
下游包可以直接通过 TypeScript declaration merging 注入新的事件类型,无需 fork 核心包:
declare module "@earendil-works/agent" {
interface CustomAgentMessages {
myCustomEvent: { payload: string };
}
}
为什么两种策略共存? ADT 追求穷举安全:已知的变体集合,编译时验证完整性。声明合并追求扩展灵活:未知的下游需求,零侵入注入。同一包里两者共存,是 Open/Closed Principle 在类型系统层面的实现——对修改封闭(ADT 核心),对扩展开放(CustomAgentMessages)。
代价:声明合并的扩展字段无法做静态穷举检查,重名字段静默覆盖,无冲突检测。
Policy Bundle vs Policy Delegation
AgentLoopConfig(types.ts,12+ 个可选字段)是典型的Policy Bundle模式——Strategy 模式(convertToLlm、transformContext)和 Hook 模式(shouldStopAfterTurn、getSteeringMessages)聚合成一个配置对象传入。调用方只需要传一个对象,loop 内部分别在正确的时机调用。
bash.ts 里的 timeout 参数(bash.ts:26)是Policy Delegation:
// bash.ts:26 — schema 注释明确写明
timeout: Type.Optional(Type.Number(...)) // "no default timeout"
框架把超时策略的决策权交给 LLM:ls 命令和 make all 的合理超时值差 3 个数量级,框架无法给一个适合所有场景的默认值,不如让 LLM 根据命令类型自适应。
代价:策略委托给了一个非确定性的 agent。LLM 忘记传 timeout 时,bash 进程可以永久运行,仅受外层 AbortSignal 保护。框架应该加一个"LLM 未传时的 safety fallback"(比如 60 秒)而不是 no timeout。
这是 Policy Delegation 的局限性:委托策略时仍然需要兜底值。
Railway-Oriented Programming 的 LLM 边界盲区
Result<T,E>(types.ts:6-38)在工具执行路径上被严格应用——工具执行失败是确定性的(exitCode、异常),Railway-Oriented 很自然。
但 generateSummary() 返回裸 Promise<string>——LLM 调用的失败是概率性的(截断、格式不合规),而且截断不是异常,是一个需要主动检测的状态:stopReason === "max_tokens" 有输出,没有抛,只有一个字段悄悄变了值。
Result 的真正价值不是把错误变成可见的,而是把恢复策略变成必须的:
// 现状:可以不处理截断
const summary = await generateSummary(...);
// 升级后:编译器强制你声明恢复路径
const result = await generateSummary(...);
if (!result.ok) {
// 不在这里写,就编译不过
}
Promise<string> 让恢复路径可以不存在,不需要任何人对此负责。这是 LLM 工程仍在学习阶段的证据:工具执行和 LLM 调用应该有同等的工程严肃性,但后者还没有得到同等对待。
INFO
架构约束
transformContext 钩子(types.ts:191)有一个显式的隐式契约:must not throw or reject。这保护了 agent loop 的事件流完整性——如果 compaction 抛出,EventStream 会在没有 agent_end 的情况下终止,消费者永久等待。这意味着所有恢复策略必须封装在 compaction 内部,agent loop 层不知情也不需要知情。
六、可靠性债务:8 个已确认的工程缺口
以下问题均有精确源码行号,经过多角色交叉验证:
| 优先级 | 位置 | 问题 | 触发条件 |
|---|---|---|---|
| P0 | agent-loop.ts:155 | 双层循环无超时兜底,parallel 工具挂死时 Promise.all 永不 settle | parallel 工具挂起 |
| P1 | agent-loop.ts:155 | block-retry 零迭代防护,确定性无限循环 | LLM 固执重试 |
| P1 | types.ts:178,191 | hooks 隐式"不得 throw"契约,违反者导致静默退出 | 钩子实现者不读文档 |
| P1 | agent-loop.ts:383 | some() 降级整批并行为串行,性能雪崩 | 混用工具模式 |
| P1 | types.ts:330,333 | messages setter 防拷贝,但 push() 绕过 setter | 任何 push() 调用 |
| P1 | compaction.ts:499-508 | max_tokens 截断静默写入 CompactionEntry | 长对话压缩命中上限 |
| P2 | types.ts:219 | char/4 CJK token 低估,压缩触发时机偏晚 | 中文密集对话 |
| P2 | compaction/utils.ts | FileOperations 命名与持久化层 FileSystem 歧义 | 新读者理解 |
P0 vs P1 的故障形态差异
同样是"无防护",两个最严重问题的故障终态不同:
P0(并行工具挂死):Promise.all 永不 resolve,streamAssistantResponse() 永不返回,agent_end 永不 emit,EventStream 消费者永久等待。只能靠 process-level timeout 外部 kill。
P1(compaction 反复降级):compact() fallback 到返回原始 messages → context 持续增长 → LLM 返回 stopReason === "error" (context window exceeded) → runLoop:196-199 检测到 error → 正常 emit agent_end → EventStream 终止。
P1 是有界失败(最终会退出),但用户看到的是误导性的"LLM 错误",而不是"compaction 压缩失败"。
compaction 截断的三层修复方案
P1 的 compaction.ts:499-508 截断问题需要三层联动修复,且全部必须封装在 compaction 内部(因为 transformContext 的 must-not-throw 约束):
检测层:generateSummary 升级为 Result<string, SummaryError>,max_tokens 时返回 err({ type: "truncated" })。
恢复层:Adaptive trim → Circuit Breaker。截断时丢弃 messagesToSummarize(compaction.ts:524)最老 20%,重试;到最小阈值仍失败则 fallback 到 originalMessages(不压缩)。
WARNING
方向不能搞反
自适应 trim 的操作对象是 messagesToSummarize,不是 keepRecentTokens。减少 keepRecentTokens 会让更多历史进入待摘要区,反而增加摘要负担,方向相反。
可观测层:CompactionEntry 新增 summaryComplete: boolean 字段。降级发生时写 false,历史 session 加载时可检测残缺条目,为事后 debug 保留唯一可审计的痕迹。
七、横向对比
工具并行执行
| pi-mono | Cline | LangChain AgentExecutor | |
|---|---|---|---|
| 实现 | 声明式模式(sequential/parallel),some() 批次降级 | 串行,无并行机制 | 串行,无原生并行 |
| 来源 | agent-loop.ts:381-388 | 据公开文档 | 据公开文档 |
| 设计哲学 | 细粒度:工具自声明副作用等级 | 保守:串行保证确定性 | 未关注 |
关键对比:串行框架的工具超时是局部失效(只影响当前工具),pi-mono 的 parallel 工具挂死是批次级失效(整批 Promise.all 阻塞)。这是并行带来效率的现实代价。
上下文压缩策略
| pi-mono | Aider | LangChain | |
|---|---|---|---|
| 压缩方式 | 增量聚合(有 previousSummary 时 O(delta)) | Repo Map(基于代码符号,不摘要对话历史) | summarize_chain,据文档为全量重摘要 |
| split-turn | Fork-Join 并发补全 | 不适用 | 无 |
| 来源 | compaction.ts:455(增量), compaction.ts:651(Fork-Join) | 据公开文档 | 据公开文档 |
pi-mono 是比较对象中唯一实现 O(delta) 成本压缩的。Aider 走了完全不同的路——压缩代码上下文而非对话历史,因为 Aider 的 Agent 循环不长,对话压缩不是核心问题。
bash 超时策略谱系
| 实现 | 工具级超时 | 循环级保护 | 设计意图 |
|---|---|---|---|
| pi-mono | LLM 自治,无默认值,无兜底(bash.ts:26) | 无 max_iterations | Policy Delegation,显式注释"no default timeout" |
| LangChain | 工具自实现,框架无入口 | max_iterations=15,max_execution_time 可配 | Leaky Abstraction,框架未解决应解决的问题 |
| Aider | subprocess(timeout=60) 硬编码 | 无显式 | 防御性硬编码,非架构决策 |
pi-mono 和 LangChain 的工具级无超时是同一行为、不同意图:pi-mono 是文档化的架构决策,责任显式转移给调用方;LangChain 更接近框架层的遗漏,社区长期有 issue 但未修复。
循环终止机制
| pi-mono | LangChain | Claude Code | |
|---|---|---|---|
| 最大轮次 | 无硬编码上限 | max_iterations=15(默认) | maxTurns 可配,据公开文档 |
| 终止语义 | every():全部工具 terminate=true | AgentFinish action(任一) | 据公开文档 |
| 来源 | agent-loop.ts:544-546 | 据公开文档 | 据公开文档 |
every() 语义是并行设计的必然:串行框架遇到一个终止工具就退出,并行框架需要等所有工具表态。
八、总结
pi-mono 展现了 LLM 系统工程中一个典型的张力:外部边界需要"看起来可靠"(不 throw、总返回 messages),内部边界需要"真正可靠"(检测截断、自适应恢复、记录降级)。
最值得借鉴的三个设计:
- 增量聚合压缩:O(delta) 的 compaction 成本,会话越长压缩越值得
- 预分配槽位保序:顺序语义编码在数据结构里,不依赖运行时行为
- per-turn 模型切换:hook 接口让 session 内的模型切换成为一等公民
最值得警惕的两个决定:
- 无 max_iterations:信任 LLM 自律,但在 block-retry 场景下这是确定性无限循环
- generateSummary 返回裸 string:LLM 调用路径上的错误处理被遗漏,Result 的覆盖盲区会在长会话的极端情况下导致无声的数据损坏
这不是批评这个项目——这些权衡反映了当前整个 AI Agent 工程领域的学习曲线。工具执行路径的错误处理模式已经相当成熟,LLM 调用路径的错误处理仍是悬而未决的工程问题,所有框架都在摸索中。
参考文献
- badlogic/pi-mono文中所有行号引用基于 2025 年 6 月克隆版本,经实测验证。