Skip to content

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
ToolResultMessage

AssistantMessage 里几个容易忽略但重要的字段:

字段用途
api / provider / model记录实际回答的模型
responseModel / responseIdprovider 报告的差异信息
thinkingLevel / providerThinkingLevelPi 请求级别和 provider 原生级别
usagetoken 与成本
stopReasonpending / 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 必须先于任何 update
  • done 或 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 消息,push error 事件再 end()

createProvider() 负责选择实现:

  • 单一 api:所有 chat 模型共用
  • api map:按 model.api 派发
  • 没有对应实现:返回一个只会发 error 事件的流

lazyApi() 再把动态 import 包装成相同的形状。

provider 适配器内部 ​

以 api/openai-responses.ts 为例:

  1. resolveTranscript() 按 compat 决定是否折叠中途的 system 消息
  2. 构造一个 stopReason: "pending" 的输出 AssistantMessage
  3. 建客户端,buildParams() 组装请求,包含消息、工具、reasoning、采样参数
  4. 调用 onPayload / onResponse 钩子
  5. stream.push({ type: "start", partial: output })
  6. processResponsesStream() 边解析上游 SSE 边更新 output 并 push delta 事件
  7. 正常结束 push done,然后 end()
  8. catch 分支:归一化错误,设置 stopReason 为 error 或 aborted,push error 再 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 字段处理,不要在适配器外假设某种能力。