Appearance
LLM 请求边界(pi-ai)
这一层回答的问题是:agent loop 看到的 AssistantMessageEventStream 到底从哪来?
调用链:
text
Agent / AgentSession
-> models.streamSimple(model, context, options)
-> normalizeContext(context) 得到 TranscriptContext
-> lazyStream(...)
-> provider.streamSimple(model, transcript, options)
-> API 适配器(如 api/openai-responses.ts)
-> new AssistantMessageEventStream()
-> 发出 start / text_* / thinking_* / toolcall_* / done | error关键包内文件:
text
packages/ai/src/types.ts 类型与协议
packages/ai/src/models.ts Models 集合、createProvider
packages/ai/src/utils/event-stream.ts AssistantMessageEventStream
packages/ai/src/utils/transcript.ts Context -> TranscriptContext、工具声明回放
packages/ai/src/api/lazy.ts 把异步 setup 包装成同步返回的流
packages/ai/src/api/*.ts 各 provider 适配器Context 与 TranscriptContext
调用方传的是 Context:
ts
{
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
}provider 看到的是 TranscriptContext。两者的区别是:systemPrompt 和 tools 会被折叠进 transcript 的前置 system 消息。
normalizeContext() 是唯一的转换入口:
text
createInitialSystemMessage(systemPrompt, tools)
-> 有内容时作为 messages[0] 插入这样做的原因是 transcript 需要自描述。回放所有 system 消息就能还原当前提示词和工具集合:
- 后续 system 消息的
content追加到基础提示词 sections按名字替换或删除toolsAdded/toolsRemoved依次应用
相关函数:
getCurrentSystemMessage()getCurrentTools()collapseSystemMessages():供不支持对话中途 system 消息的 provider 使用resolveTranscript():根据 compat 选择保留还是折叠
Message 结构
四个角色:
text
SystemMessage
UserMessage
AssistantMessage
ToolResultMessageAssistantMessage 里几个容易忽略但重要的字段:
| 字段 | 用途 |
|---|---|
api / provider / model | 记录实际回答的模型 |
responseModel / responseId | provider 报告的差异信息 |
thinkingLevel / providerThinkingLevel | Pi 请求级别和 provider 原生级别 |
usage | token 与成本 |
stopReason | pending / stop / length / toolUse / error / aborted / deferred |
errorMessage | 失败原因 |
diagnostics | 脱敏后的诊断数据 |
durationMs | 由事件流写入 |
content block 里有三种可回放签名:
- 文本:
textSignature - 思考:
thinkingSignature(可能包含加密的 redacted 内容) - 工具调用:
thoughtSignature(Google 特定)
这些签名的存在意味着 transcript 不只是给人看的,还要能原样回传给 provider 保持多轮一致性。
ToolResultMessage 有 content(文本或图片)、details、usage、nestedCalls、isError、durationMs。注意 structuredContent 不在这一层,它是 agent 层 AgentToolResult 的字段,不发给模型。
请求选项
三层继承关系:
text
ProviderRequestOptions 基础请求控制
signal, apiKey, fetch, env, headers, timeoutMs,
maxRetries, maxRetryDelayMs, onPayload, onResponse
StreamOptions provider 通用流选项
+ temperature, maxTokens, transport, cacheRetention,
sessionId, metadata, onProviderStreamEvent
SimpleStreamOptions provider 中立选项
+ toolChoice, reasoning, deferred, thinkingBudgets每个 API 的具体选项类型通过 ApiOptionsMap 映射,例如 openai-responses 对应 OpenAIResponsesOptions。
StreamFunction 的契约
ts
type StreamFunction = (
model: Model<Api>,
context: TranscriptContext,
options?: StreamOptions,
) => AssistantMessageEventStream;契约要点:
- 收到的是已经归一化的
TranscriptContext - 必须返回
AssistantMessageEventStream - 直接调用
streamSimple()时,缺少认证可以同步抛错 - 一旦返回了流,请求、模型、运行时错误都必须编码成事件,不能再 reject
- 错误终止时,最终的 assistant 消息
stopReason是error或aborted,并带errorMessage
agent 包里的 StreamFn 形状相同,只是允许返回 promise。这样 Models.streamSimple 可以直接作为默认 stream function。
AssistantMessageEventStream
底层是通用的 EventStream<T, R>:
push(event)入队或交给等待中的消费者end(result)结束result()返回最终结果 promise[Symbol.asyncIterator]()支持for await
AssistantMessageEventStream 在此之上:
- 完成条件:
done或error - 最终结果:
done.message或error.error - 记录
durationMs:从流创建到结束,除非消息已有该字段或时间戳早于流的创建时间
最后一条规则保证「转发别处已经开始的响应」不会被记为本次请求耗时,deferred 结果就属于这种情况。
事件协议
text
start
text_start / text_delta / text_end
thinking_start / thinking_delta / thinking_end
toolcall_start / toolcall_delta / toolcall_end
done | error顺序约束:
start必须先于任何 updatedone或error终止流- setup 阶段失败可以直接以
error终止,没有start partial是「到目前为止的实时对象」,不是某一刻的快照;*_start时块是空的,*_delta逐步填充,*_end才是权威值
Models 如何派发到 provider
ModelsImpl 实现 Models 接口。streamSimple() 的流程:
text
normalizeContext(context)
lazyStream(model, async () => {
assertChatModel(model)
prepareRequest(model, options) // 解析 auth、baseUrl、headers、env
provider.streamSimple(requestModel, transcript, options)
})lazyStream() 的作用是让函数同步返回,把异步 setup 藏在流后面:
- 内层流的事件被逐个转发
- setup 抛错时,构造一条
stopReason: "error"的 assistant 消息,pusherror事件再end()
createProvider() 负责选择实现:
- 单一
api:所有 chat 模型共用 apimap:按model.api派发- 没有对应实现:返回一个只会发 error 事件的流
lazyApi() 再把动态 import 包装成相同的形状。
provider 适配器内部
以 api/openai-responses.ts 为例:
resolveTranscript()按 compat 决定是否折叠中途的 system 消息- 构造一个
stopReason: "pending"的输出AssistantMessage - 建客户端,
buildParams()组装请求,包含消息、工具、reasoning、采样参数 - 调用
onPayload/onResponse钩子 stream.push({ type: "start", partial: output })processResponsesStream()边解析上游 SSE 边更新output并 push delta 事件- 正常结束 push
done,然后end() - catch 分支:归一化错误,设置
stopReason为error或aborted,pusherror再end()
Agent 侧如何消费
agent-loop.ts 的 streamAssistantResponse():
text
for await (event of response)
start -> partial 放进 context,发 message_start
*_delta / *_end -> 替换 context 中最后一条,发 message_update
done | error -> 取 result(),发 message_end所以 loop 里 context 持有一条「实时部分消息」,UI 通过 message_update 看到它逐步增长。
易错点
- 创建流是同步的;通过
Models或Agent拿到的流不会因为请求错误而 reject,错误会以error事件出现在流里。 - 只有直接调用某个 API 模块的
streamSimple()时,缺少认证才可能同步抛错。 durationMs只对「看到过 start」的响应计时。- provider 差异通过
compat字段处理,不要在适配器外假设某种能力。