Pi:模型层——大模型调度内核与“推理“支柱
系列入口:[[解剖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.ts 和 models.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-ai 的 ProviderRequestOptions 是每个请求都有的配置面(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_p、top_k、min_p、repetition_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 总量里拆出来单独记账,成本归因能精确到"这一块钱是缓存命中省下来的"。
六、停止原因:把"模型为什么停了"标准化
模型响应的结束方式被标准化成 StopReason(session-format.md 里出现过):
stop | length | toolUse | error | aborted | pending | deferred
每个值对应一种明确的后续:
stop→ 正常结束,检查有没有工具调用;length→ 输出被 token 上限切断,可能截断工具调用参数 → 批内调用全部不执行;toolUse→ 有工具调用,进入工具批次;error→ 错误,落账后走重试/失败分类;aborted→ 被取消,区分"有 abort 标记"和"provider 中断"两种;deferred→ 请求被挂起,持久化 handle 等以后取;pending→ 仅用于流式事件中间态,永不出现在落盘会话里(“Terminaldone/errormessages 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 的系统提示词不是写死的字符串,它有一条完整的构建链:
- 上下文文件(
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”)。这是项目的"宪法",对模型最优先。 - skills 目录——只列
<available_skills>清单,按需读取,不常驻。 - prompt templates——
/name触发的 Markdown 片段,展开成完整 prompt(prompt-templates.md),支持位置参数${1:-default}。 before_run钩子可以覆盖系统提示词——“systemPrompt?: string; // persisted override, fixed for the run”,并且这个覆盖是持久化在operation_started记录里的。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.ts、packages/agent/src/harness/system-prompt.ts、packages/coding-agent/docs/settings.md(thinking level)、packages/coding-agent/docs/prompt-templates.md、packages/agent/docs/harness-v2.md(溢出分类/钩子)。
更多推荐
所有评论(0)