第二章的桌面界面只关心 text_delta。真实 Agent 还需要告诉用户:
- 模型是否正在 Thinking;
- 请求为什么结束;
- 使用了多少 Token;
- 花费了多少;
- 请求是否被用户取消;
- 失败时已经生成了哪些部分内容。
这些信息并不是额外查询得到的。Pi 把它们放在同一条事件流和最终 AssistantMessage 中。
1. 先扩展桌面界面状态
我们把上一章的 ReplyState 扩展为:
type ReplyState = {
phase:
| "idle"
| "connecting"
| "streaming"
| "thinking"
| "answering"
| "done"
| "error"
| "aborted";
thinking: string;
text: string;
usage?: Usage;
stopReason?: StopReason;
error?: string;
};
Pi 不要求桌面应用使用这个状态结构。这是课程示例,用来把事件协议转成用户能理解的界面状态。
connecting 应由应用在调用 models.streamSimple() 前设置。收到 start 后再进入 streaming。统一事件协议只把 start 定义为消息流开始,不把它定义为“正在建立网络连接”。
2. 模型是否支持 Thinking
Model 接口使用 reasoning: boolean 表示模型是否支持推理能力:
if (!model.reasoning) {
// 界面不显示 Thinking Level 选择器
}
但只判断布尔值还不够。Pi 在类型上区分两个概念:
type ThinkingLevel =
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "max";
type ModelThinkingLevel =
| "off"
| ThinkingLevel;
ThinkingLevel 用于请求中的推理强度,不包含 "off";ModelThinkingLevel 用于描述模型可选状态,因此额外包含 "off"。
模型还可以通过 thinkingLevelMap 把 Pi 级别映射成模型自己的值,或者用 null 表示某一级别不支持。
桌面应用应使用:
const levels = getSupportedThinkingLevels(model);
而不是假设所有推理模型都支持全部级别。getSupportedThinkingLevels() 返回 ModelThinkingLevel[]。源码中的规则是:
reasoning为false时,只返回"off";reasoning为true时,"off"仍可以被thinkingLevelMap.off = null明确移除;"minimal"到"high"默认可用,除非映射明确为null;"xhigh"和"max"必须由模型显式提供映射才会出现。
如果请求的级别不可用,clampThinkingLevel() 会从请求级别开始先向更高级别查找,再向较低级别查找;若仍找不到,则回退到能力列表首项,列表为空时回退到 "off"。
源码位置:
packages/ai/src/types.ts中的ThinkingLevel、ModelThinkingLevel和Modelpackages/ai/src/models.ts中的getSupportedThinkingLevels
3. streamSimple() 中的 Simple 是什么
stream() 接受具体 API 的 Provider 专用参数;streamSimple() 则提供跨 Provider 的统一 reasoning 参数:
const stream = models.streamSimple(model, context, {
reasoning: "medium",
});
SimpleStreamOptions 在普通 StreamOptions 基础上只增加:
export interface SimpleStreamOptions extends StreamOptions {
reasoning?: ThinkingLevel;
thinkingBudgets?: ThinkingBudgets;
}
源码位置:packages/ai/src/types.ts 中的 SimpleStreamOptions
这里的 reasoning 类型是 ThinkingLevel,不能直接传 "off"。在 streamSimple() 请求中,不传 reasoning 就表示不主动启用推理。"off" 主要用于模型能力列表和更高层的 Agent 状态。
“Simple”不是说它不支持流,也不是另一种简化模型协议。它表示 Pi 帮应用把统一的 Thinking Level 转换成 Provider 需要的参数。
以 OpenAI Responses 为例,streamSimple() 会:
- 调用
buildBaseOptions()整理通用参数; - 使用
clampThinkingLevel()把请求级别限制到模型支持的范围; - 把结果转换成
reasoningEffort; - 调用同文件中的
stream()。
源码位置:packages/ai/src/api/openai-responses.ts 中的 streamSimple
4. Thinking 如何进入事件流
启用 Thinking 后,在上一章的事件循环中增加三种事件:
for await (const event of stream) {
switch (event.type) {
case "thinking_start":
setReply((state) => ({
...state,
phase: "thinking",
thinking: "",
}));
break;
case "thinking_delta":
setReply((state) => ({
...state,
thinking: state.thinking + event.delta,
}));
break;
case "thinking_end":
break;
case "text_start":
setReply((state) => ({
...state,
phase: "answering",
}));
break;
}
}
事件结构与文本完全平行:
flowchart LR
TS["thinking_start"] --> TD["thinking_delta × N"]
TD --> TE["thinking_end"]
XS["text_start"] --> XD["text_delta × N"]
XD --> XE["text_end"]
但“平行”不等于“Thinking 一定全部结束后才开始文本”。仓库文档明确说明不同内容块的事件可能交错。因此桌面界面应按内容块保存状态,而不是写死一条不可打断的时间线。
5. Thinking 最终保存在哪里
最终 AssistantMessage.content 中,Thinking 是一个独立内容块:
export interface ThinkingContent {
type: "thinking";
thinking: string;
thinkingSignature?: string;
redacted?: boolean;
}
源码位置:packages/ai/src/types.ts 中的 ThinkingContent
thinkingSignature 不是给用户看的文本。源码注释说明,它可以保存 Provider 需要在后续请求中继续传回的签名或加密数据。redacted 为 true 时,Thinking 内容已经被安全过滤,透明文本不一定可用,但不透明载荷仍可能保存在签名字段中用于多轮连续性。
因此桌面应用可以显示 thinking,但不应该把 thinkingSignature 当作普通文本展示。
6. 普通回答使用 TextContent
普通回答文字是另一个独立内容块:
export interface TextContent {
type: "text";
text: string;
textSignature?: string;
}
源码位置:packages/ai/src/types.ts 中的 TextContent
text 是桌面界面显示的正文。textSignature 是可选的 Provider 消息元数据,用于后续请求兼容,不应该拼接到回答正文中。
Thinking 和普通回答之所以分成两个内容块,是因为它们的显示策略、签名数据和生命周期都不同。
7. 一次请求的 Token 数据
最终消息中的 usage 结构如下:
export interface Usage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cacheWrite1h?: number;
reasoning?: number;
totalTokens: number;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
}
源码位置:packages/ai/src/types.ts 中的 Usage
需要注意两个容易误读的字段:
reasoning是output的子集,不能再把它加到output上计算总量;cacheRead和cacheWrite被单独记录,不等于普通输入 Token。
totalTokens 是 Provider 报告的总量。以 OpenAI Responses 为例,Pi 直接使用 response.usage.total_tokens,而不是用其他分类字段在本地重新相加。因此应用不应假设它永远等于几个分类字段的简单求和。
桌面应用可以这样显示:
function formatUsage(usage: Usage) {
return [
`输入 ${usage.input}`,
`输出 ${usage.output}`,
`缓存读取 ${usage.cacheRead}`,
`总计 ${usage.totalTokens}`,
`费用 $${usage.cost.total.toFixed(4)}`,
].join(" · ");
}
8. Usage 和成本是怎样填入的
以 OpenAI Responses 适配器为例,终止响应到达时,它读取 Provider 返回的 usage:
output.usage = {
input: Math.max(
0,
(response.usage.input_tokens || 0)
- cachedTokens
- cacheWriteTokens,
),
output: response.usage.output_tokens || 0,
cacheRead: cachedTokens,
cacheWrite: cacheWriteTokens,
reasoning:
response.usage.output_tokens_details?.reasoning_tokens || 0,
totalTokens: response.usage.total_tokens || 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
calculateCost(model, output.usage);
源码位置:packages/ai/src/api/openai-responses-shared.ts 中的 finalizeResponse
calculateCost() 根据 Model.cost 中的每百万 Token 价格计算各部分费用。如果模型配置了请求级价格阶梯,它会选择输入量超过阈值的最高匹配档位。
这里还有三个不能省略的源码细节:
- 价格阶梯的判断输入量是
input + cacheRead + cacheWrite; - 最高匹配档位会应用到整次请求,不只是超出阈值的部分;
- Anthropic 一小时缓存写入使用基础输入价格的两倍计算。
calculateCost() 计算的是基础成本。Provider 还可以继续做特定调整。OpenAI Responses 在基础计算后调用 applyServiceTierPricing():
flex把各项费用乘以0.5;priority通常乘以2;gpt-5.5的priority乘以2.5;- 其他服务层级乘数为
1,不再调整。
源码位置:packages/ai/src/api/openai-responses.ts 中的 getServiceTierCostMultiplier 和 applyServiceTierPricing
源码位置:packages/ai/src/models.ts 中的 calculateCost
因此费用不是桌面应用根据模型名称猜出来的。基础值来自模型目录价格和 Provider Usage,最终值还可能包含 Provider 适配器明确实现的计价调整。
9. 五种停止原因
每个最终 AssistantMessage 都有 stopReason:
type StopReason =
| "stop"
| "length"
| "toolUse"
| "error"
| "aborted";
它们分别表示:
| 值 | 含义 | 桌面界面建议 |
|---|---|---|
stop |
正常结束 | 标记回答完成 |
length |
达到输出长度限制 | 提示回答可能不完整 |
toolUse |
模型请求调用工具 | 进入工具执行阶段 |
error |
生成过程失败 | 显示失败状态和可重试操作 |
aborted |
AbortSignal 取消请求 | 显示“已停止”而不是“系统失败” |
前三种是 done 事件允许携带的原因;后两种通过 error 事件表达。
源码位置:packages/ai/src/types.ts 中的 StopReason 和 AssistantMessageEvent
10. Pi 为什么把错误也变成消息
StreamFunction 的源码契约写明:
- 调用后发生的请求、模型和运行时失败应该编码到返回的 Stream;
- 实现不应该把这些失败直接抛到 Stream 外;
- 错误终止必须产生
stopReason为"error"或"aborted"的AssistantMessage。
源码位置:packages/ai/src/types.ts 中的 StreamFunction
这是实现契约中的“应该”。具体 raw API adapter 在返回 Stream 前仍可能做同步预检查,例如 OpenAI Responses 和 Mistral 的 API Key 检查可能同步抛出。
桌面应用通常调用的是 Models.stream() 或 Models.streamSimple() 公共入口。这两个入口使用外层 lazyStream() 包住认证、Provider 准备和 adapter 调用,从而把这些 setup throw 转换成 Stream error 事件。
在这个公共入口上,桌面应用可以用同一个事件循环处理:
- 已经生成的部分文字;
- 最终 Usage;
- 停止原因;
- 错误说明。
例如网络在回答到一半时断开,OpenAI Responses 适配器不会清空已经写进 output.content 的内容。它在 catch 中设置 stopReason 和 errorMessage,然后推送 error 事件。
源码位置:packages/ai/src/api/openai-responses.ts 中的 stream
11. 初始化失败也会进入同一条错误路径
请求甚至可能在 Provider Stream 创建前失败,例如:
- 找不到 Provider;
- 没有配置认证;
- lazy API 模块加载失败。
lazyStream() 会捕获 setup() 的失败,创建一个空内容的 AssistantMessage,然后推送:
{
type: "error",
reason: "error",
error: message,
}
源码位置:packages/ai/src/api/lazy.ts 中的 lazyStream
因此通过 models.streamSimple() 调用时,应用应监听 error 事件,而不是只在函数调用外面写一个 try/catch。
外层 try/catch 仍然可以保护应用自己的事件处理代码,但 Provider 请求失败的主要协议是 Stream error event。
12. 用户点击“停止”时发生什么
桌面应用可以把一个 AbortSignal 传给 Pi:
const controller = new AbortController();
const stream = models.streamSimple(model, context, {
reasoning: "medium",
signal: controller.signal,
});
stopButton.onclick = () => {
controller.abort();
};
AbortSignal 是协作式取消。当请求已经进入支持 AbortSignal 的 Provider 适配器,并且适配器观察到 Signal 已取消时,它通常会把最终消息的 stopReason 设置为 "aborted",并通过 error 事件结束。
不能把所有 setup 阶段失败都称为 aborted。lazyStream() 的 setup catch 固定产生 reason: "error";只有具体请求路径识别出取消时,终止原因才是 "aborted"。
对于桌面界面,aborted 应与普通错误区分:
case "error":
setReply((state) => ({
...state,
phase:
event.reason === "aborted"
? "aborted"
: "error",
error: event.error.errorMessage,
usage: event.error.usage,
stopReason: event.reason,
}));
break;
被取消的最终消息仍然可能包含取消前已经生成的内容和 Usage。仓库文档还说明,这条消息可以加入 Context,在下一次请求中继续对话。
13. Provider 错误为什么还要标准化
不同 SDK 抛出的 HTTP 错误字段不一样:
- Mistral 可能使用
statusCode; - OpenAI 和 Google SDK 可能使用
status; - Bedrock 可能把状态放在
$metadata或$response; - 原始响应体也可能出现在不同字段。
normalizeProviderError() 会从这些已知结构中提取状态码、响应体和消息,formatProviderError() 再组合成一致的可显示文本。响应体最长保留 4000 个字符。
源码位置:packages/ai/src/utils/error-body.ts
这是 Provider 适配层的错误标准化,不等于桌面应用必须把完整错误原样展示给终端用户。课程示例建议把简洁错误放在消息区,把经过敏感信息过滤的诊断放入开发日志;这项显示和过滤策略属于应用设计,不是 Pi 强制规则。
14. diagnostics 保存什么
AssistantMessage 还有一个可选的 diagnostics 数组。单条诊断的结构包括:
export interface AssistantMessageDiagnostic {
type: string;
timestamp: number;
error?: {
name?: string;
message: string;
stack?: string;
code?: string | number;
};
details?: Record<string, unknown>;
}
源码位置:
packages/ai/src/types.ts中的AssistantMessage.diagnosticspackages/ai/src/utils/diagnostics.ts
errorMessage 表示这条最终消息的主要错误说明;diagnostics 可以保存更细的 Provider 或运行时失败与恢复信息。
createAssistantMessageDiagnostic() 会保留 Error 的 message、stack 和可识别 code。正因为其中可能含有 stack 或其他内部信息,应用在持久化或展示前需要自行检查并过滤敏感内容。
15. 把所有事件归约到界面状态
下面只保留本章关心的分支:
function reduceReply(
state: ReplyState,
event: AssistantMessageEvent,
): ReplyState {
switch (event.type) {
case "start":
return { ...state, phase: "streaming" };
case "thinking_start":
return { ...state, phase: "thinking" };
case "thinking_delta":
return {
...state,
thinking: state.thinking + event.delta,
};
case "text_start":
return { ...state, phase: "answering" };
case "text_delta":
return {
...state,
text: state.text + event.delta,
};
case "done":
return {
...state,
phase: "done",
usage: event.message.usage,
stopReason: event.reason,
};
case "error":
return {
...state,
phase: event.reason,
usage: event.error.usage,
stopReason: event.reason,
error: event.error.errorMessage,
};
default:
return state;
}
}
实际支持多个内容块时,Thinking 和文本都应像第 2 章一样按 contentIndex 保存。本例把它们合并为字符串,只为突出生命周期、Usage 和错误处理。
16. 本章小结
model.reasoning表示模型是否支持推理。ThinkingLevel不含"off",ModelThinkingLevel包含"off"。getSupportedThinkingLevels()返回具体模型可用的ModelThinkingLevel。streamSimple()把统一reasoning参数转换成 Provider 参数。- 普通文字和 Thinking 分别使用
TextContent与ThinkingContent。 - Thinking 使用独立的 start、delta 和 end 事件。
- 最终 Thinking 保存在
ThinkingContent,签名字段不用于普通展示。 Usage.reasoning已包含在Usage.output中。- 基础成本由模型价格数据和 Provider Usage 计算,Provider 还可能进行明确的二次调整。
stopReason区分正常结束、长度限制、工具、错误和取消。- 通过
Models.stream*公共入口时,setup 与请求失败统一进入 Stream error。 - 用户取消使用
AbortSignal;请求路径识别到取消时,最终状态是aborted。 diagnostics可以记录比errorMessage更细的失败和恢复信息。
下一章会在这些能力数据之上实现模型选择器,并解释 Provider、模型目录和认证如何共同决定“哪些模型真的可用”。
17. 自测
- 为什么不能用
model.reasoning推断模型一定支持"max"? Usage.reasoning是否应该再次加到Usage.output?done和error分别允许哪些停止原因?- Provider 请求失败时,为什么只在
streamSimple()外写try/catch不够? - 用户取消后,已经生成的部分内容是否一定会被清空?
- 为什么
Models.streamSimple()与直接调用 raw adapter 的同步错误边界不同?
本章源码依据
packages/ai/src/types.tspackages/ai/src/models.tspackages/ai/src/api/simple-options.tspackages/ai/src/api/lazy.tspackages/ai/src/api/openai-responses.tspackages/ai/src/api/openai-responses-shared.tspackages/ai/src/utils/event-stream.tspackages/ai/src/utils/error-body.tspackages/ai/src/utils/diagnostics.tspackages/ai/README.md的 Thinking/Reasoning、Stop Reasons、Error Handling