Main Chain B · Step 09
prepareRequest + buildRequest:从日志折叠请求
agent.ts:501-620。这是「模型可见 ⟺ 已记录」最集中的实现:请求的每个部分——config、system、tools、messages——全部从日志折叠而来,然后冻结成一个不可变对象。0.1.5 起它拆成两个函数:prepareRequest(解析配置 + 绑定 adapter,501 行)与 buildRequest(组装冻结请求,553 行)——因为 system prompt 的投影决策需要先知道 adapter 的能力(systemPromptUpdate)。
示例本次示例:step 1 请求的折叠实况
# 调用(11 页 340 行):
# buildRequest(1, 1, assembly.tools, system, session.deriveMessages(), signal)
# 其中 boundaryMessages = deriveMessages() 的当前投影:
# [ user/message「帮我列一下当前目录」(seq=2) ] ← 从日志投影,不是内存对象
# ① 折叠上次配置(510 行):日志里还没有 request/header → persistedHeader=undefined
# → seedConfig = { provider:'deepseek-official', model:'deepseek-v4-pro',
# reasoningEffort:'max' } ← 来自 AgentOptions
# ② agent/request waterfall(457 行):无监听器改写 → proposedConfig = seedConfig
# ③ prepareCall(468 行):绑定 adapter 注册 + 物化模型默认值
# → config = { ...proposedConfig, maxTokens: 模型目录的默认 32768 }
# (真实默认:llm-deepseek 的 DEFAULT_MAX_TOKENS)
# ④ header 落盘(551-562 行):reason:'initial'
# { "type":"request/header", "data":{ "header":{
# "config":{...}, "adapterDefaults":{maxTokens:true}, "system":"...", "tools":[...] },
# "reason":"initial" } }
# ⑤ 最终请求(638 行):markAgentLoopRequest(Object.freeze({...}))
# step 2 的 buildRequest:baseline = 上次的 header(agent.ts:595 requestHeader())
# config/system/tools 都没变 → headerEquals(baseline, header) === true
# → 不落新 request/header 事件(601 行的 else-if 不命中)
# → 日志里 header 历史只有一条 initial——「配置如何演变」的记录是稀疏的
§1完整代码与行级解读
529 private async prepareRequest(turn: number, step: number, signal: AbortSignal): Promise<{ config; preparedCall? }> { // 拆分 1解析配置 + 绑定 adapter
538 const persistedHeader = session.requestHeader()
539 const persistedConfig = persistedHeader?.config
541 const persistedReasoningEffort = persistedConfig?.provider === route.provider
543 && persistedHeader?.adapterDefaults?.reasoningEffort !== true
546 const reasoningEffort = this.options.reasoningEffort ?? persistedReasoningEffort
547 const maxTokens = this.options.maxTokens
548 const seedConfig = deepFreeze(structuredClone(
558 const proposedConfig = await this.dispatch.waterfall(
559 'agent/request', { turn, step, signal },
562 signal.throwIfAborted()
563 if (!proposedConfig.provider || !proposedConfig.model) {
564 throw new Error(`agent "${this.id}" has no provider/model: ...`)
565 }
566 let config: LlmCallConfig
567 let preparedCall: PreparedLlmCall | undefined
568 try {
569 preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal)
570 config = preparedCall.config
571 } catch (error: unknown) {
573 if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
574 config = proposedConfig
575 }
576 signal.throwIfAborted()
577 return { config, ...preparedCall === undefined ? {} : { preparedCall } } // 交给调用方step 用它投影 system
578 }
581 private buildRequest(config: LlmCallConfig, preparedCall: PreparedLlmCall | undefined,
585 startsRequestSeries: boolean, signal: AbortSignal): GenerateOptions { // 拆分 2
588 const { session } = this
589 const surfaceGeneration = session.surface.contentGeneration // 0.1.6:contentGeneration含插件改写
590 const header = canonicalHeader({
591 config,
592 ...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults },
593 ...tools.length > 0 ? { tools } : {}, // 注意:没有 system 字段
594 })
595 const baseline = this.session.requestHeader()
596 const startsSeries = startsRequestSeries
597 || this.requestSurfaceGeneration !== surfaceGeneration
598 if (!this.requestHeaderLogged) {
599 this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
600 this.requestHeaderLogged = true
601 } else if (baseline === undefined || !headerEquals(baseline, header)) {
612 const contextWindow = preparedCall?.context?.contextWindow
613 const systemPromptUpdate = preparedCall?.systemPromptUpdate
614 const requestContext: RequestContext = {
615 provider: config.provider,
616 model: config.model,
617 ...contextWindow === undefined ? {} : { contextWindow },
618 ...systemPromptUpdate === undefined ? {} : { systemPromptUpdate },
619 }
620 const previousContext = session.requestContext()
621 if (previousContext?.provider !== requestContext.provider
622 || previousContext.model !== requestContext.model
623 || previousContext.contextWindow !== requestContext.contextWindow
624 || previousContext.systemPromptUpdate !== requestContext.systemPromptUpdate) {
625 session.append('request/context', requestContext)
626 }
627 signal.throwIfAborted()
630 deepFreeze(header)
631 const boundaryMessages = session.deriveMessages() // 现在在此 derive
632 for (const message of boundaryMessages) {
633 if (this.frozenMessages.has(message)) continue
634 deepFreeze(message)
635 this.frozenMessages.add(message)
636 }
637 Object.freeze(boundaryMessages)
638 const request = markAgentLoopRequest(Object.freeze({
644 }))
645 return request // 不再返回 preparedCall(它由 prepareRequest 单独返回)
646 }
prepareRequest(0.1.5 新增):只做「解析请求配置 + 绑定 adapter」——从日志折叠上次 header(510-518)、agent/request waterfall(530-532)、prepareCall 冻结绑定(541)、NO_ADAPTER 宽容(545-546)。返回 { config, preparedCall } 交给调用方。**它不碰日志、不组装请求**——纯解析。
buildRequest 现在是同步纯函数:接收已解析的 config/preparedCall/tools,折叠 canonicalHeader(562-566)。注意 header 里没有 system 字段(agent.ts:565)——0.1.5 起 system prompt 不在 header 里(它在 surface 上,由 step 的 systemPrompt.project() 落盘为 system/message)。
header 落盘三路(initial/resume · change 可附 startsSeries · series),与 0.1.2 引入的 series 机制一致。
request/context 新增 systemPromptUpdate 维度:记录该路由的 prompt 更新能力('in-history' 或缺席)。能力变化也会触发落盘(agent.ts:596)。这是 0.1.5 的新元数据——step 的 systemPrompt.project() 用它决定「能否在历史中间追加 prompt」。
boundaryMessages 现在在这里 derive(agent.ts:603 session.deriveMessages())——不再由 step 传入。这正是「模型可见 ⟺ 已记录」的强化:请求的 messages 完全由日志派生,连参数都不给外部传的机会。
组装冻结请求(markAgentLoopRequest(Object.freeze({...})))并返回。不再返回 preparedCall——它由 prepareRequest 单独返回,两者在 step 里汇合(11 页 362/379 行)。
§40.1.3-alpha.2 新增:request/header 的 series 维度
这一版为 request/header 引入「消息系列」概念。背景:模型对话可以包含多个显式区分的消息系列(例如 compaction 之后的新系列、跨模型代际的系列切换)——若 header 完全没变,日志里就没有「这里开始了一个新系列」的持久标记,下游投影/续跑无法定位系列边界。
- 两个触发源(
agent.ts:568-569):①preStep的 enter 决定带startsRequestSeries: true(08 页 PreparedStep,插件显式宣告);② surface 的replaceGeneration变了(compaction 的 replace 重写过 surface)——step 在每次迭代时快照surfaceGeneration(11 页),buildRequest 拿它和requestSurfaceGeneration(上一个已建请求的代数)比较。 - 落盘三条路(
agent.ts:570-581):见上表——initial/resume 只落一次;change 可附startsSeries;series 是「header 未变但系列开始」的专门 reason。 - 类型层:
RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series'(session/src/types.ts:261),request/header数据新增可选startsSeries?: true(types.ts:369)。
§2requestProposal:adapter 默认值的剥离
62/** Remove adapter-derived values before plugins propose the next request config. */
63function requestProposal(header: EpochHeader): LlmCallConfig {
64 if (header.adapterDefaults === undefined) return header.config
65 const proposal = { ...header.config }
66 if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort
67 if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens
68 return proposal
69}
上一次请求的 config 里可能混着 adapter 物化出来的默认值(调用方没指定,adapter 按模型目录补上的)。如果把它们原样作为下一次提案的种子,切换模型后这些值会「粘」在新模型上。所以:凡是 adapterDefaults 标记过的字段,从提案里删掉,让下一个 adapter 重新物化自己的默认值。这就是 agent.ts:513-518「effort 只在同模型时恢复」的姊妹逻辑。
§3prepareCall:一次性派发 + 代际绑定
prepareCall 在 packages/llm/llm/src/index.ts:921(10 页会看到 LlmRuntime 全貌)。这里看它的全文(llm/src/index.ts:921-965)——尤其 systemPromptUpdate(947 行,0.1.5 新增)与 stream 闭包的两道守卫:
921 async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall> {
922 const registration = this.registration(config.provider)
923 const adapterCall = await registration.adapter.prepareCall(config.provider, config.model, signal)
924 const modelInfo = this.normalizeModelInfo(registration, config.model, adapterCall.model)
925 const resolved = this.resolveCallWithInfo(config, modelInfo)
926 const resolvedConfig = deepFreeze(structuredClone(resolved.config))
927 const context = resolved.context === undefined ? undefined : deepFreeze(structuredClone(resolved.context))
930 const adapterDefaults = deepFreeze<LlmCallConfigAdapterDefaults>({标注哪些是 adapter 补的
931 ...config.reasoningEffort === undefined && resolvedConfig.reasoningEffort !== undefined
932 ? { reasoningEffort: true }
933 : {},
934 ...config.maxTokens === undefined && resolvedConfig.maxTokens !== undefined
935 ? { maxTokens: true }
936 : {},
937 })
938 let dispatched = false
939 return Object.freeze({
940 config: resolvedConfig,
941 retryPolicy: registration.retryPolicy,
942 adapterDefaults,
943 ...context === undefined ? {} : { context },
944 ...modelInfo.inputModalities === undefined
945 ? {}
946 : { inputModalities: Object.freeze([...modelInfo.inputModalities]) },
947 ...modelInfo.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: ... },0.1.5 新增能力位
948 stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => {
949 if (dispatched) {
950 throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL')
951 }
952 if (!callConfigEquals(options, resolvedConfig)) {
953 throw new LlmError(
954 'prepared LLM call config changed before adapter dispatch',
955 'INVALID_PREPARED_CALL',
956 )
957 }
958 dispatched = true
959 return this.streamWithRegistration(options, {
960 registration, config: resolvedConfig, modelInfo,
961 dispatch: options => adapterCall.stream(options),
962 })
963 },
964 })
965 }
两个守卫:① dispatched——一个 prepared call 只能派发一次;② callConfigEquals——派发时的请求 config 必须与 prepare 时解析出的 config 完全一致。违者 INVALID_PREPARED_CALL。0.1.5 起返回对象新增 systemPromptUpdate(942 行)——路由声明它能否读 in-history 的 system prompt,供 11 页 step 的投影决策使用。
返回对象整体 freeze;resolvedConfig 在 928 行已 deepFreeze + structuredClone。还携带 inputModalities 与 systemPromptUpdate(942 行,0.1.5 新增)(该 adapter 代际捕获的精确模型模态,同样冻结)——prepare 之后的一切都是不可变的。
0.1.3 机制:代际绑定。prepareCall 现在先调 registration.adapter.prepareCall(provider, model, signal)(LlmAdapter.prepareCall,llm/src/index.ts:266,动态 adapter 可 override)拿到与「最终 stream 调用」同一代际的精确模型元数据(PreparedAdapterCall),再 resolve 配置——设置变更发生在 prepare 与 dispatch 之间时,不可能把一代的能力与另一代的 endpoint 拼起来。
为什么需要这个机制?配置树可以热重组(04 页)。一次 prepare 把能力结果(contextWindow、adapterDefaults、retryPolicy、inputModalities)绑定到那个时刻的 adapter 注册与模型代际。如果不守卫,HMR 后可能把 A adapter 的能力结果和 B adapter 的请求拼在一起。prepared call 要么原样用掉,要么明确失败,绝不静默换 adapter。