系列入口:[[解剖Pi-八支柱视角下的极简Agent架构]](总纲)

一期五篇拆完了"执行、会话、工具、安全、落地"。但有一个核心支柱当时整篇缺席:推理。模型层是整个 Agent 公式里"大模型"那一半——Agent = 大模型 + 运行时 Harness

这篇拆 packages/ai(pi-ai),它自己只有一句话定位:Unified multi-provider LLM API(统一多 provider LLM API)。但就是这么一层"统一 API",撑起了模型适配、流式、重试、超时、缓存、推理级别、上下文交接全部能力。


一、pi-ai 在整条链路里的位置

回到总纲的链路图,模型层是 ② 模型调用 LLMGateway 那一站。Pi 的实现把这一站做成了独立包 @earendil-works/pi-ai,它和上层 harness 的关系一句话说清:

agent-loop.ts  (循环)
   │  streamFunction(model, context, options)   ← 注入的模型调用函数
   ▼
pi-ai  (统一多 provider 适配层)
   ├─ model-catalog.ts       模型目录:provider 支持哪些模型、窗口多大
   ├─ api/*.ts               每个 provider 一个适配器
   ├─ types.ts               Message / Tool / Usage / Context / StopReason
   └─ event 流               start / text_delta / toolcall_delta / done / error

关键设计:agent-loop 不直接依赖任何 provider SDK,它只认一个注入的 streamFunction 换 provider 就是换模型目录 + 换适配器,循环代码一行不改。这就是"统一接口 + 适配器"在 Agent 里的标准形态。

二、模型适配器:一家一适配,业务只认一张脸

pi-ai 的 api/ 目录就是适配器清单(从 packages/ai/src/api/ 列出):

anthropic-messages.ts      OpenAI-completions.ts      OpenAI-responses.ts
azure-openai-responses.ts  google-generative-ai.ts    google-vertex.ts
mistral-conversations.ts   bedrock-converse-stream.ts openai-codex-responses.ts
github-copilot-headers.ts  cloudflare.ts              constrained-sampling.ts

每个适配器做三件事:把 pi 的中立 Message[] 翻译成该厂商的 wire 格式 → 发请求 → 把响应流统一成 pi 的事件流start / text_delta / text_end / toolcall_delta / done / error)。

适配器之上是 model-catalog.tsmodels.generated.ts——模型目录是生成出来的,描述每个 provider 支持哪些模型、上下文窗口多大、采样参数是什么。这带来一个工程红利:pi update --models 可以只刷新模型目录,不改代码。换模型、加模型在 Pi 里是"数据更新"而"代码更新"。

三、推理级别的配置:thinking level 是显式的

Pi 把"思考深度"做成了一等配置,而不是提示词里的一句话。settings 里有 defaultThinkingLevel,取值是完整的档位:

off / minimal / low / medium / high / xhigh / max

还支持按档位配 token 预算:

{
  "thinkingBudgets": {
    "minimal": 1024,
    "low": 4096,
    "medium": 10240,
    "high": 32768
  }
}

关键在生命周期:thinking level 是会话状态的一部分,随 lane_config 整体替换(专项二讲过),并且 thinking_level_change 会作为一条会话记录落账。它还能在会话中途切换——/model 换模型、调 thinking level,后续 bash 命令看到的 PI_REASONING_LEVEL 立刻更新(env 文档原话:“Switching models or changing the reasoning level therefore affects the next bash command without restarting Pi”)。推理深度从"写死在提示词里的词"变成"可运行时切换、可审计的状态"。

四、请求级选项:超时、重试、缓存的全部开关

pi-aiProviderRequestOptions 是每个请求都有的配置面(ai-types.ts 原文):

export interface ProviderRequestOptions<TModel = Model<Api>> {
    signal?: AbortSignal;
    apiKey?: string;
    fetch?: FetchFunction;                 // 可注入自定义 fetch
    env?: ProviderEnv;                     // provider 级环境覆盖
    headers?: ProviderHeaders;             // 自定义头,null 可压制默认头
    timeoutMs?: number;                    // HTTP 超时,OpenAI/Anthropic SDK 默认 10 分钟
    maxRetries?: number;                   // 客户端重试次数,OpenAI/Anthropic SDK 默认 2
    maxRetryDelayMs?: number;              // 服务端要求长等待时的上限,默认 60s
    onPayload?: (payload, model) => ...;   // 发前钩子:可检查/替换请求体
    onResponse?: (response, model) => ...; // 收响应后、读 body 前的钩子
    telemetryContext?: TelemetryContext;   // 挂到上层 trace 上
}

注意几个语义:

  • 超时和重试是 SDK 级参数,由每个适配器透传。Pi 自己不实现重试算法,而是信任各家 SDK 的客户端重试(默认 2 次);真正决定"要不要重试、重试到第几次"的是harness 层的 durable attempts两层重试各司其职:SDK 管"一次 HTTP 请求的瞬时抖动",harness 管"一次生成尝试的持久化重试"。
  • maxRetryDelayMs 有个聪明的设计:服务端要求长等待且超过上限时,请求立刻失败并带上"服务端要求的延迟",把决策权交还给上层——“让更高层的重试逻辑用用户可见的方式处理它”。不在 SDK 里无限等。
  • onPayload 是请求体注入点。harness 的 before_payload 钩子(专项一目录里那个)就是接在这里的——“provider 专属 wire payload,最后一道闸”。

StreamOptions 还扩展了流相关选项:

transport?: "sse" | "websocket" | "websocket-cached" | "auto";   // 传输层选择
cacheRetention?: "none" | "short" | "long";                      // prompt 缓存保留
sessionId?: string;        // provider 会话缓存/路由
websocketConnectTimeoutMs?: number;
samplingParams?: Record<string, unknown>;   // llama.cpp/vLLM 的 top_p/top_k/min_p 透传

samplingParams 是给自部署服务留的口子:Pi 没建模的采样参数(top_ptop_kmin_prepetition_penalty)可以通过它原样透传给 OpenAI 兼容服务。这意味着 pi-ai 既能接云 API,也能接本地 vLLM/SGLang/llama.cpp,而不需要改适配器代码

五、Prompt 缓存:cacheRetention 和三段 token

pi-ai 把"成本"和"缓存"做成了一等概念。Usage 结构(ai-types.ts):

interface Usage {
    input: number;
    output: number;
    cacheRead: number;     // 命中缓存的 token
    cacheWrite: number;    // 写入缓存的 token
    cacheWrite1h?: number; // Anthropic 特有的 1h 保留档
    reasoning?: number;    // 推理 token(output 的子集)
    totalTokens: number;
    cost: { input; output; cacheRead; cacheWrite; total };
}

缓存策略由 cacheRetention: "none" | "short" | "long" 声明,PI_CACHE_RETENTION=long 可以全局切到长保留。这条设计直接回应了总纲《LLM上下文窗口管理》里"Token 就是钱"的教训——pi-ai 把 cacheRead/cacheWrite 从 token 总量里拆出来单独记账,成本归因能精确到"这一块钱是缓存命中省下来的"。

六、停止原因:把"模型为什么停了"标准化

模型响应的结束方式被标准化成 StopReasonsession-format.md 里出现过):

stop | length | toolUse | error | aborted | pending | deferred

每个值对应一种明确的后续:

  • stop → 正常结束,检查有没有工具调用;
  • length → 输出被 token 上限切断,可能截断工具调用参数 → 批内调用全部不执行
  • toolUse → 有工具调用,进入工具批次;
  • error → 错误,落账后走重试/失败分类;
  • aborted → 被取消,区分"有 abort 标记"和"provider 中断"两种;
  • deferred → 请求被挂起,持久化 handle 等以后取;
  • pending仅用于流式事件中间态,永不出现在落盘会话里(“Terminal done/error messages replace it with a completion reason before pi persists the assistant message”)。

这个枚举的价值在于:下游逻辑(重试/截断/工具/挂起/失败)全部挂在 stopReason 的分支上,模型返回的"为什么停"是结构化数据,而不是文本里猜。 你的 LLMGateway 也应该把"结束原因"标准化——这是响应解析器的地基。

七、上下文压缩与溢出检测:把"超限"做成分级事件

总纲讲上下文管理时用"降级链";Pi 在模型层把"超限"做成了**可识别的溢出分类:

function isRecoverableLength(message: AssistantMessage, intendedOutputLimit: number): boolean {
  return message.stopReason === "length"
    && intendedOutputLimit > 0
    && message.usage.output < intendedOutputLimit;
}

溢出判定三连:显式 context-limit 错误(“prompt is too long”)、报告输入超过捕获窗口(input + cacheRead > contextWindow)、可恢复的 length。命中溢出 → 不启动工具批次,先做一次针对该响应的 overflow compaction,而"每次会话输入最多触发一次溢出压缩"(triggerMessageId 限制)。

这里能看到模型层和会话层的对接:"上下文窗口是物理约束"这件事,在 Pi 里由 model-catalog(窗口大小)+ reserveTokens(缓冲)+ 溢出分类 + compaction 四件套共同处理,环环相扣。

八、Prompt 生命周期:系统提示词从哪里来、谁来管

Pi 的系统提示词不是写死的字符串,它有一条完整的构建链:

  1. 上下文文件AGENTS.md / CLAUDE.md / .pi/SYSTEM.md / .pi/APPEND_SYSTEM.md)——即使项目未信任也加载(security.md 原话:“Context files such as AGENTS.md, CLAUDE.md are loaded regardless of project trust”)。这是项目的"宪法",对模型最优先。
  2. skills 目录——只列 <available_skills> 清单,按需读取,不常驻。
  3. prompt templates——/name 触发的 Markdown 片段,展开成完整 prompt(prompt-templates.md),支持位置参数 ${1:-default}
  4. before_run 钩子可以覆盖系统提示词——“systemPrompt?: string; // persisted override, fixed for the run”,并且这个覆盖是持久化operation_started 记录里的。
  5. transform_context 钩子——“AgentMessage 级别,发往 provider 之前。剪枝、注入、custom-message 处理。Ephemeral: shapes what the provider sees, never what the session contains”——这是"只改 provider 看到的、不改会话存储的"最后一层变换。

合起来是一条完整生命周期:项目宪法(AGENTS.md)→ 技能目录(skills)→ 用户模板(prompts)→ 运行时覆盖(before_run)→ 发前变换(transform_context)。每个环节的职责和持久化语义都明确——这回答了"系统提示词生命周期"这个缺口:它在 Pi 里是一份有版本、可覆盖、分环节的构建产物。

九、对照总纲:模型层这个支柱到底要做什么

把 pi-ai 拆完,回到八支柱里"推理"该有的样子。给模型层做一个能力自检清单:

模型层能力Pi 的做法你要不要做
多 provider 适配每厂一适配器 + 统一事件流必须(接两家以上就要)
多模型热切换model_change 记录 + 运行时换 model有跨模型对比需求就要
流式消息处理text_delta/toolcall_delta 逐事件推进必须(TTFB 体验)
超时/重试SDK 级参数 + harness 级 durable attempts 双层必须
结束原因标准化StopReason 枚举必须(重试/截断都挂在它上面)
成本记账cacheRead/cacheWrite 拆分 + usage 随消息必须(Token 是钱)
Prompt 缓存cacheRetention + sessionId强烈建议(省钱)
上下文压缩overflow 分类 + compaction必须(窗口是物理约束)
推理级别thinking level 状态化 + 预算看场景(复杂任务才需要)
请求体注入onPayload / before_payload强烈建议(拦截/审计挂点)

模型层做的是一件被很多人忽略的事:把"调用大模型"从"拼 prompt 调 API"升级成"带生命周期的调度"。 生命周期 = 模型选择有状态、结束原因结构化、成本精确到缓存档、超限是分类事件、提示词是分环节构建产物。做到这五条,你的 LLMGateway 才真正配叫"内核"。


知识卡片(本节体系归档)

┌──────────────────────────────────────────────────────────┐
│ 知识节点:模型层(带生命周期的模型调度)                      │
│                                                          │
│ What  统一多 provider 适配器 + thinking level 状态化 +      │
│       ProviderRequestOptions(timeout/retry/cache)+       │
│       StopReason 枚举 + 溢出分类 + Prompt 生命周期          │
│                                                          │
│ Why   一般原理:把"调大模型"升级成"带生命周期的调度"。         │
│       不变量:                                             │
│       ① 结束原因结构化(StopReason),下游靠枚举分支          │
│       ② 成本精确到缓存档(cacheRead/cacheWrite 拆分)        │
│       ③ 推理级别是会话状态,可切换可审计                     │
│                                                          │
│ How   校验动作: 
│       画适配器层 + PaiFlow 加 StopReason 枚举               │
│                                                          │
│ Pits  坑点:       
│       超时写死 / 结束原因靠文本猜 / 缓存没记账               │
│                                                          │
│ Transfer 到 PaiFlow:LLMGateway 加标准 StopReason + usage 拆档 │
└──────────────────────────────────────────────────────────┘

源码与文档出处:packages/ai/src/types.ts(ProviderRequestOptions / StreamOptions / Usage / Context)、packages/ai/src/api/(provider 适配器)、packages/ai/src/model-catalog.tspackages/agent/src/harness/system-prompt.tspackages/coding-agent/docs/settings.md(thinking level)、packages/coding-agent/docs/prompt-templates.mdpackages/agent/docs/harness-v2.md(溢出分类/钩子)。

更多推荐