跳到主内容
创建时间:最后更新:作者:白川

处理模型回复与流式输出

分清 stream、streamSimple 与 completeSimple 的差别,按 Transport 选择传输方式,并正确处理结束原因与用量。

处理模型回复与流式输出

普通请求等完整结果后再显示,流式请求则可以边生成边呈现。两者的业务任务相同,区别在于程序需要正确处理"还没结束的输出"和"已经定稿的结果"。

沿用模型配置中的项目、connection.ts 和一组可用的 .env。本篇先不注册工具,只观察文本生成。

四个入口的区别

模型层提供四个发起请求的方法。它们的区别不在"要不要流式",而在参数形态和返回形态:

方法上下文类型选项类型返回
models.stream(model, ctx, opts)
Context
ApiStreamOptions<TApi>
AssistantMessageEventStream
models.complete(model, ctx, opts)
Context
ApiStreamOptions<TApi>
Promise<AssistantMessage>
models.streamSimple(model, ctx, opts)
Context
SimpleStreamOptions
AssistantMessageEventStream
models.completeSimple(model, ctx, opts)
Context
SimpleStreamOptions
Promise<AssistantMessage>

stream / complete 走 provider 原生的参数类型,streamSimple / completeSimple 走统一抽象(provider-neutral)的参数类型。SimpleStreamOptions 里多出来的字段是这些跨 provider 的开关:

typescript
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 映射到它自己完整的选项类型:

typescript
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() 的实现只有一行:

typescript
class ModelsImpl {
	async completeSimple(
		model: Model<Api>,
		context: Context,
		options?: ModelsSimpleStreamOptions,
	): Promise<AssistantMessage> {
		return this.streamSimple(model, context, options).result();
	}
}

也就是说 completeSimple 就是 streamSimple(...).result()——它照样走流式协议,只是把中间事件收起来,直接给你最终消息。

选用建议:

需求用哪个
逐字显示、需要增量 UI
streamSimple / stream
只要最终结果,一行代码
completeSimple
需要某 provider 专属参数
stream / complete
写一个跨 provider 的通用封装
streamSimple / completeSimple

创建流式显示程序

新建 src/stream-response.ts:

typescript
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)

运行:

bash
node --env-file=.env --import tsx src/stream-response.ts

text_delta 表示新增文本,用 stdout.write() 追加。模型如果很快完成,终端看起来可能接近一次性输出;是否消费了事件应检查代码或记录,而不是只凭动画速度判断。

结束后调用 stream.result() 取得完整消息。这里用 responseText() 检查状态,但不再次打印全文,否则会把已经显示的增量重复显示一遍。

事件协议有十二种

AssistantMessageEvent 是一个可辨识联合类型,共 12 个成员:

事件载荷用途
start
partial
流开始,partial 是当前累积消息
text_start
contentIndex
文本块开始
text_delta
contentIndex、delta
文本增量
text_end
contentIndex、content
文本块完成
thinking_start
contentIndex
思考块开始
thinking_delta
contentIndex、delta
思考增量
thinking_end
contentIndex、content
思考块完成
toolcall_start
contentIndex
工具调用开始
toolcall_delta
contentIndex、delta
工具参数 JSON 增量
toolcall_end
contentIndex、toolCall
工具调用完成,参数完整
done
reason、message
正常结束
error
reason、error
失败结束

协议顺序有硬约束。源码注释:

Successful streams emit start before partial updates and terminate with done. A stream may terminate directly with error when request setup fails before generation starts; after start, failures also terminate with error. Updates and done must never appear before start.

所以只有两种合法序列: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 partial is 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 的类型是:

typescript
export interface Context {
	systemPrompt?: string;
	messages: Message[];
	tools?: Tool[];
}

systemPrompt 和 tools 只是简写。真正发给 provider 之前,normalizeContext() 会把它们折叠成转录(transcript)里的首条系统消息:

typescript
/**
 * 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 代码里:

typescript
export type TranscriptContext = {
	messages: Message[];
	readonly [transcriptContextBrand]: true;
};

消息内容可能包含文本、图片、工具请求或思考块。不要用 String(content) 代替内容块处理,也不要假设第一个内容块一定是最终文本。

结束原因有七种

StopReason 是一个七值联合类型:

typescript
export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";
结束原因应如何处理
pending
尚未定稿,不接受为最终结果
stop
本轮模型正常结束,再检查内容是否符合任务
length
输出被截断,不能假装是完整结果
toolUse
模型需要工具结果,应进入工具处理流程
error
保存错误原因,报告本次未完成
aborted
已请求中止,保留需要的部分输出并结束运行
deferred
Provider 交回了可延迟取回的句柄

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 里只有四个取值:

typescript
export type Transport = "sse" | "websocket" | "websocket-cached" | "auto";
取值含义
sse
Server-Sent Events,服务端单向流
websocket
WebSocket,双向长连接
websocket-cached
复用已缓存的 WebSocket 会话
auto
由 provider 自行决定(默认值)

这个选项挂在 StreamOptions.transport 上:

typescript
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 适配层的降级逻辑值得单独记住,因为它是"能自动恢复"和"不能自动恢复"的分界:

typescript
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;

翻译成规则:

失败时机phase行为
消息流开始之前
before_message_stream_start
落回 SSE 重试,诊断里记 fallbackTransport: "sse"
消息流开始之后
after_message_stream_start
不再降级,直接抛错

原因很实际:流已经开始意味着部分内容已经发给用户或写进上下文,换一条线重放会导致内容重复。失败被记录为一条 AssistantMessageDiagnostic,挂在 AssistantMessage.diagnostics 数组上——诊断信息是脱敏过的。

另外注意这里有"连接数超限"和"上一次响应不存在"两种情况的重试(不是降级):它们在流开始前失败时会重试一次 WebSocket。

还有一条独立于 transport 的机制:websocket-cached 与会话级的 SSE 回退记录。同一会话里 WebSocket 连续失败后,后续请求直接走 SSE,不再尝试 WebSocket。

其它传输相关选项

StreamOptions 里还有几个和传输配合的字段:

typescript
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 的结构:

typescript
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 提供的独立计算函数,会处理缓存分层定价:

typescript
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 估算,用于上下文管理:

typescript
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 报告为准。

验收结果

正常请求只打印一次正文,之后输出结束原因和用量。把输出预算调小观察截断,把时间上限调短观察中止。触发条件依赖服务生成情况,不应把未复现某种状态当作程序已经覆盖了它。

模拟检查可以稳定制造这些状态,真实模型效果仍需使用自己的账号验证。

参考资料