返回课程首页

3

展示 Thinking、Token 和错误状态

把模型内部状态转换为桌面应用可以展示的数据。

源码基线:Pi v0.82.0 · commit 518855d

第二章的桌面界面只关心 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[]。源码中的规则是:

  • reasoningfalse 时,只返回 "off"
  • reasoningtrue 时,"off" 仍可以被 thinkingLevelMap.off = null 明确移除;
  • "minimal""high" 默认可用,除非映射明确为 null
  • "xhigh""max" 必须由模型显式提供映射才会出现。

如果请求的级别不可用,clampThinkingLevel() 会从请求级别开始先向更高级别查找,再向较低级别查找;若仍找不到,则回退到能力列表首项,列表为空时回退到 "off"

源码位置:

  • packages/ai/src/types.ts 中的 ThinkingLevelModelThinkingLevelModel
  • packages/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() 会:

  1. 调用 buildBaseOptions() 整理通用参数;
  2. 使用 clampThinkingLevel() 把请求级别限制到模型支持的范围;
  3. 把结果转换成 reasoningEffort
  4. 调用同文件中的 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 需要在后续请求中继续传回的签名或加密数据。redactedtrue 时,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

需要注意两个容易误读的字段:

  • reasoningoutput 的子集,不能再把它加到 output 上计算总量;
  • cacheReadcacheWrite 被单独记录,不等于普通输入 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.5priority 乘以 2.5
  • 其他服务层级乘数为 1,不再调整。

源码位置:packages/ai/src/api/openai-responses.ts 中的 getServiceTierCostMultiplierapplyServiceTierPricing

源码位置: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 中的 StopReasonAssistantMessageEvent

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 中设置 stopReasonerrorMessage,然后推送 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.diagnostics
  • packages/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 分别使用 TextContentThinkingContent
  • 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. 自测

  1. 为什么不能用 model.reasoning 推断模型一定支持 "max"
  2. Usage.reasoning 是否应该再次加到 Usage.output
  3. doneerror 分别允许哪些停止原因?
  4. Provider 请求失败时,为什么只在 streamSimple() 外写 try/catch 不够?
  5. 用户取消后,已经生成的部分内容是否一定会被清空?
  6. 为什么 Models.streamSimple() 与直接调用 raw adapter 的同步错误边界不同?

本章源码依据

  • packages/ai/src/types.ts
  • packages/ai/src/models.ts
  • packages/ai/src/api/simple-options.ts
  • packages/ai/src/api/lazy.ts
  • packages/ai/src/api/openai-responses.ts
  • packages/ai/src/api/openai-responses-shared.ts
  • packages/ai/src/utils/event-stream.ts
  • packages/ai/src/utils/error-body.ts
  • packages/ai/src/utils/diagnostics.ts
  • packages/ai/README.md 的 Thinking/Reasoning、Stop Reasons、Error Handling