处理模型回复与流式输出
分清 stream、streamSimple 与 completeSimple 的差别,按 Transport 选择传输方式,并正确处理结束原因与用量。
处理模型回复与流式输出
普通请求等完整结果后再显示,流式请求则可以边生成边呈现。两者的业务任务相同,区别在于程序需要正确处理"还没结束的输出"和"已经定稿的结果"。
沿用模型配置中的项目、connection.ts 和一组可用的 .env。本篇先不注册工具,只观察文本生成。
四个入口的区别
模型层提供四个发起请求的方法。它们的区别不在"要不要流式",而在参数形态和返回形态:
stream / complete 走 provider 原生的参数类型,streamSimple / completeSimple 走统一抽象(provider-neutral)的参数类型。SimpleStreamOptions 里多出来的字段是这些跨 provider 的开关:
export interface SimpleStreamOptions extends StreamOptions {
/** Provider-neutral tool selection for simple requests. When omitted, adapters use provider-specific behavior. */
toolChoice?: ToolChoice;
reasoning?: ThinkingLevel;
/** Ask a capable provider to return a durable handle and continue the request asynchronously. */
deferred?: boolean | { window?: "15m" | "1h" | "24h" };
/** Custom token budgets for thinking levels (token-based providers only) */
thinkingBudgets?: ThinkingBudgets;
}reasoning 用 Pi 统一的档位名(off、minimal、low、medium、high、xhigh、max),由各 provider 适配层映射到自己的参数。toolChoice 目前只有 "auto" 和 "none" 两个取值。
而 ApiStreamOptions<TApi> 会把已知 API 映射到它自己完整的选项类型:
export interface ApiOptionsMap {
"anthropic-messages": AnthropicOptions;
"openai-completions": OpenAICompletionsOptions;
"openai-responses": OpenAIResponsesOptions;
"openai-codex-responses": OpenAICodexResponsesOptions;
"azure-openai-responses": AzureOpenAIResponsesOptions;
"google-generative-ai": GoogleOptions;
"google-vertex": GoogleVertexOptions;
"mistral-conversations": MistralOptions;
"bedrock-converse-stream": BedrockOptions;
"pi-messages": PiMessagesOptions;
}
export type ApiStreamOptions<TApi extends Api> = TApi extends keyof ApiOptionsMap
? ApiOptionsMap[TApi]
: StreamOptions & Record<string, unknown>;所以用 stream 时可以拿到该 API 专属的参数(比如 Anthropic 的 thinkingBudgetTokens),用 streamSimple 则只能拿到跨 provider 的通用字段。要 provider 专属参数就用 stream;要跨 provider 一致就用 streamSimple。
completeSimple() 的实现只有一行:
class ModelsImpl {
async completeSimple(
model: Model<Api>,
context: Context,
options?: ModelsSimpleStreamOptions,
): Promise<AssistantMessage> {
return this.streamSimple(model, context, options).result();
}
}也就是说 completeSimple 就是 streamSimple(...).result()——它照样走流式协议,只是把中间事件收起来,直接给你最终消息。
选用建议:
创建流式显示程序
新建 src/stream-response.ts:
import { createConnection, responseText } from "./connection.js"
const { models, model } = createConnection()
const stream = models.streamSimple(
model,
{
messages: [
{ role: "user", content: "简要解释数组索引,并给一个例子。", timestamp: Date.now() },
],
},
{ signal: AbortSignal.timeout(30_000), maxTokens: 512 },
)
for await (const event of stream) {
if (event.type === "text_delta") {
process.stdout.write(event.delta)
}
if (event.type === "error") {
console.error("\n生成状态:", event.reason)
}
}
const final = await stream.result()
responseText(final)
console.log("\n结束原因:", final.stopReason)
console.log("用量:", final.usage)运行:
node --env-file=.env --import tsx src/stream-response.tstext_delta 表示新增文本,用 stdout.write() 追加。模型如果很快完成,终端看起来可能接近一次性输出;是否消费了事件应检查代码或记录,而不是只凭动画速度判断。
结束后调用 stream.result() 取得完整消息。这里用 responseText() 检查状态,但不再次打印全文,否则会把已经显示的增量重复显示一遍。
事件协议有十二种
AssistantMessageEvent 是一个可辨识联合类型,共 12 个成员:
协议顺序有硬约束。源码注释:
Successful streams emit
startbefore partial updates and terminate withdone. A stream may terminate directly witherrorwhen request setup fails before generation starts; afterstart, failures also terminate witherror. Updates anddonemust never appear beforestart.
所以只有两种合法序列:start → updates* → done,或者 start → updates* → error(以及请求建立就失败时的单独 error)。
还有一个容易踩的坑:不同内容块的事件不保证连续。provider 可能在同一个上游 chunk 里同时发出文本和工具调用的事件,Pi 也会交错呈现,比如 text_start、text_delta、toolcall_start、text_delta、toolcall_delta。消费方必须用 contentIndex 关联事件,不能假设某个块的 *_start / *_delta / *_end 序列 uninterrupted。
partial 也不是事件时刻的快照:
Every non-terminal event's
partialis the shared live response-so-far helper. It is intentionally not an event-time snapshot: providers may mutate the same message and content blocks as generation advances, including while older events wait in the stream queue.
要保留历史状态,得自己复制。
先把输入放对位置
本例只有一条用户消息。需要长期行为要求时放在 systemPrompt;本次任务放在用户消息;模型回复放回上下文时保留完整助手消息,而不是只保存屏幕上的文字。
Context 的类型是:
export interface Context {
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
}systemPrompt 和 tools 只是简写。真正发给 provider 之前,normalizeContext() 会把它们折叠成转录(transcript)里的首条系统消息:
/**
* Request input accepted by the public stream entry points (`Models.stream()`,
* `streamSimple()`, ...). `systemPrompt` and `tools` are shorthand for a leading
* system message; `normalizeContext()` folds them into one before the request
* reaches a provider.
*/TranscriptContext 带一个品牌标记,保证只有 normalizeContext() 能产出它,原始 Context 不会意外流到 provider 代码里:
export type TranscriptContext = {
messages: Message[];
readonly [transcriptContextBrand]: true;
};消息内容可能包含文本、图片、工具请求或思考块。不要用 String(content) 代替内容块处理,也不要假设第一个内容块一定是最终文本。
结束原因有七种
StopReason 是一个七值联合类型:
export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";pending 只在流中间出现;deferred 对应 SimpleStreamOptions.deferred 请求的延迟响应,句柄放在 AssistantMessage.deferred 字段里。
结束事件只表示模型请求的结局,不说明用户任务已经正确完成。解释函数时仍要核对代码;生成业务数据时还需要字段和业务约束校验。
AssistantMessage 还带一个 rawStopReason 字段,保留 provider 的原始停止原因字符串。它的用途是区分 Anthropic 的 end_turn、pause_turn、stop_sequence——这三种在 Pi 里都归到同一个 stop,但语义不同。
Transport:请求走哪条线
Transport 指请求的实际传输方式。packages/ai/src/types.ts 里只有四个取值:
export type Transport = "sse" | "websocket" | "websocket-cached" | "auto";这个选项挂在 StreamOptions.transport 上:
interface StreamOptions extends ProviderRequestOptions<Model<Api>> {
/**
* Preferred transport for providers that support multiple transports.
* Providers that do not support this option ignore it.
*/
transport?: Transport;
// ...
}关键前提:绝大多数 provider 不支持这个选项,会直接忽略它。 目前唯一有真实实现的路径是 packages/ai/src/api/openai-codex-responses.ts。
降级规则
Codex Responses 适配层的降级逻辑值得单独记住,因为它是"能自动恢复"和"不能自动恢复"的分界:
appendAssistantMessageDiagnostic(
output,
createAssistantMessageDiagnostic("provider_transport_failure", error, {
configuredTransport: transport,
...(websocketStarted ? {} : { fallbackTransport: "sse" }),
eventsEmitted: websocketStarted,
phase: websocketStarted ? "after_message_stream_start" : "before_message_stream_start",
requestBytes: new TextEncoder().encode(bodyJson).byteLength,
}),
);
recordWebSocketFailure(cacheSessionId, error);
if (websocketStarted) {
throw error;
}
recordWebSocketSseFallback(cacheSessionId);
break;翻译成规则:
原因很实际:流已经开始意味着部分内容已经发给用户或写进上下文,换一条线重放会导致内容重复。失败被记录为一条 AssistantMessageDiagnostic,挂在 AssistantMessage.diagnostics 数组上——诊断信息是脱敏过的。
另外注意这里有"连接数超限"和"上一次响应不存在"两种情况的重试(不是降级):它们在流开始前失败时会重试一次 WebSocket。
还有一条独立于 transport 的机制:websocket-cached 与会话级的 SSE 回退记录。同一会话里 WebSocket 连续失败后,后续请求直接走 SSE,不再尝试 WebSocket。
其它传输相关选项
StreamOptions 里还有几个和传输配合的字段:
interface StreamOptions extends ProviderRequestOptions<Model<Api>> {
/** WebSocket connect timeout in milliseconds for providers that support WebSocket transports. This covers the connection/open handshake only; stream idleness after connection uses timeoutMs. */
websocketConnectTimeoutMs?: number;
/** HTTP request timeout in milliseconds for providers/SDKs that support it. For example, OpenAI and Anthropic SDK clients default to 10 minutes. */
timeoutMs?: number;
/** Maximum retry attempts for providers/SDKs that support client-side retries. For example, OpenAI and Anthropic SDK clients default to 2. */
maxRetries?: number;
/**
* Maximum delay in milliseconds to wait for a retry when the server requests a long wait.
* If the server's requested delay exceeds this value, the request fails immediately with an error
* containing the requested delay, allowing higher-level retry logic to handle it with user visibility.
* Default: 60000 (60 seconds). Set to 0 to disable the cap.
*/
maxRetryDelayMs?: number;
// ...
}websocketConnectTimeoutMs 只管握手,不管流建立后的空闲——空闲由 timeoutMs 管。maxRetryDelayMs 的语义值得注意:它是上限不是延时器。服务端要求的等待超过这个上限时,请求会立即失败并把服务端要求的时长带在错误里,让上层决定怎么处理,而不是干等。
中途失败时保留什么
流开始之后仍可能发生错误。已经显示的片段只是部分内容,错误消息和最终 stopReason 要一起处理,不能把片段直接入库为成功答案。
本例 30 秒总时间上限会传给模型调用。取消后,Provider 有机会中止网络等待;已经产生的 Token 仍可能计入用量,取消不代表账单归零。
用量与成本
AssistantMessage.usage 的结构:
export interface Usage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
/** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */
cacheWrite1h?: number;
/**
* Reasoning/thinking tokens, when the provider reports them. This is a subset of
* `output`: `output` already includes these tokens. Set to a number (possibly 0) by
* providers that expose a reasoning breakdown; left undefined by providers that don't.
*/
reasoning?: number;
totalTokens: number;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
/** Best available total: provider-reported billed cost when supported, otherwise the catalog estimate. */
total: number;
/** True when `total` is the billed cost reported by the provider. False when it is a catalog estimate; absent also means false. */
totalFromProvider?: boolean;
};
}四个容易记错的点:
cacheWrite1h是cacheWrite的子集,不是并列项。只有 Anthropic 报告这个拆分。reasoning也是output的子集——output已经包含推理 token。把它再加一遍就重复计算了。cost.total是"最佳可得总额":provider 报告了账单金额就用它,否则用目录估价。cost.totalFromProvider为true表示total是 provider 报的账单;为false或缺失都表示是目录估价。历史会话里没有这个字段,也按false理解。
cost 的四个分项(input、output、cacheRead、cacheWrite)始终是目录估价,可能加不出 provider 报的总额。
calculateCost() 是 Pi 提供的独立计算函数,会处理缓存分层定价:
export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] {
const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
let rates: ModelCostRates = model.cost;
let matchedThreshold = -1;
for (const tier of model.cost.tiers ?? []) {
if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {
rates = tier;
matchedThreshold = tier.inputTokensAbove;
}
}
// Anthropic charges 2x base input for 1h cache writes.
const longWrite = usage.cacheWrite1h ?? 0;
const shortWrite = usage.cacheWrite - longWrite;
usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000;
// ... 其余 cost 分项 ...
}两个细节:输入总量(input + cacheRead + cacheWrite)用于匹配阶梯价格档位;Anthropic 的 1 小时缓存写入按基础输入价的 2 倍计费,所以 cacheWrite 被拆成短写入和长写入两段分别计算。
本地估算不等于账单
packages/ai/src/utils/estimate.ts 提供了一套本地 token 估算,用于上下文管理:
const CHARS_PER_TOKEN = 3.5;
const ESTIMATED_IMAGE_CHARS = 4800;
export function estimateTextTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN);
}3.5 字符每 token 是这个文件里的硬编码常量,CHARS_PER_TOKEN = 3.5。它是一个保守的经验值:中文一个字通常 1 个 token 左右,英文大约 4 个字符 1 个 token,取 3.5 偏保守。图片按固定 4800 字符折算。
这个估算只用于本地决策(要不要压缩上下文、要不要裁剪),不能当作计费依据。真实用量以 usage 里的 provider 报告为准。
验收结果
正常请求只打印一次正文,之后输出结束原因和用量。把输出预算调小观察截断,把时间上限调短观察中止。触发条件依赖服务生成情况,不应把未复现某种状态当作程序已经覆盖了它。
模拟检查可以稳定制造这些状态,真实模型效果仍需使用自己的账号验证。