第 6 章 · 02 control.Compose 与 ride the turn tail ★


文档摘要

第 6 章 · 02 control.Compose 与 ride the turn tail ★ 本节摘要:本节是全书高潮的中段,精读 Cache-first 契约的执行者—— 。它的核心手法叫 "ride the turn tail"(骑在 turn 尾部):所有"新信息"(记忆更新、后台任务完成通知、自动召回的相关事实、目标运行时块、计划模式标记、响应/推理语言偏好)都追加到本轮用户消息的尾部,绝不碰系统提示前缀。这样 DeepSeek 的 prefix cache 每 turn 都能命中前缀,只重算尾部新 token。

第 6 章 · 02 control.Compose 与 ride the turn tail ★

本节摘要:本节是全书高潮的中段,精读 Cache-first 契约的执行者——control.Compose。它的核心手法叫 "ride the turn tail"(骑在 turn 尾部):所有"新信息"(记忆更新、后台任务完成通知、自动召回的相关事实、目标运行时块、计划模式标记、响应/推理语言偏好)都追加到本轮用户消息的尾部,绝不碰系统提示前缀。这样 DeepSeek 的 prefix cache 每 turn 都能命中前缀,只重算尾部新 token。本节用 internal/control/input.gocomposeWithGoal 源码逐段证明这一点,讲清"为什么是尾部追加而不是改前缀",对比"前缀变化"的代价(全量重算 KV),并说明 turn tail 的边界(何时不得不 compact,触发一次受控的 cache reset)。这是 Reasonix 把"推理优化"做成"系统工程"的第二块、也是最精巧的一块拼图。

内容来源:原项目源码 internal/control/input.go(Compose / composeWithGoal)、internal/control/memory.go(turn-tail 队列)、internal/control/controller.goREASONIX.md,精读并套用体系化模板。

⚠️ 注意:本节的灵魂是一句口诀——"新东西追加到 tail,前缀字节不动"。理解这一点,Compose 那段看似在"拼字符串"的代码,每一行都成了有意识的设计。如果你读 input.go 只看到"字符串拼接",就错过了整个 Reasonix 的核心机制。

学习目标

阅读完本节,你应当能够:

  1. 用一句话说清 "ride the turn tail" 的含义,以及它为什么能让 prefix cache 每 turn 命中。
  2. 逐段读懂 composeWithGoal 源码,指出每一段追加的是什么、为什么必须追加到尾部。
  3. 解释"记忆更新 <memory-update>"为什么"this turn 生效、下 turn 进前缀",而不是直接改前缀。
  4. 区分"追加到 turn tail"(本节)与"改前缀"(下一节 compact)的代价差别。
  5. 说清 auto-recall(自动召回)为什么"只骑真实用户 turn 的 tail",不骑合成恢复 turn。
  6. 解释 <hook-context> / <background-jobs> / 计划模式标记 / 语言偏好各自的追加时机。
  7. 指出 turn tail 的边界:什么时候追加也救不了,必须 compact(触发一次受控 cache reset)。

一、Compose 的定位:前缀与尾部之间的"交通管制"

上一节讲了:前缀(base prompt + tools schema + memory 索引)必须字节稳定,prefix cache 才能命中。但一个真实的 agent 会话里,每 turn 都有新东西要告诉模型:

  • 用户可能 mid-session 加了一条记忆(#note 或 remember 工具)。
  • 后台跑的任务(job)可能刚完成,模型得知道。
  • 根据用户这一轮的话,可能召回了几条相关的事实(remember 存的)。
  • 当前可能在跑一个 Goal(目标),或处于 Plan(计划)模式。
  • 用户可能指定了响应语言/推理语言。

这些信息都得让模型看到,但又绝不能改前缀(改了就 cache miss)。怎么办?Reasonix 的回答是:control.Compose 把所有这些新信息追加到本轮用户消息的文本尾部,作为用户消息的一部分发给模型。前缀字节不动,新信息虽然在尾部、本 turn 要算 token,但下一 turn 它就成了"历史消息"(位于前缀之后、turn tail 之前的对话历史),前缀依然稳定。

这就是 "ride the turn tail"——新信息"骑"在每 turn 消息的尾巴上,顺带发给模型,而不去惊动前缀。

二、composeWithGoal 源码逐段精读

Compose 的实现入口在 internal/control/input.go:

func (c *Controller) Compose(text string) string { return c.compose(text, text, true) } func (c *Controller) compose(text, source string, includeHookContext bool) string { goal, goalStatus, goalResearchMode, autoResearchTaskID := c.goals.snapshot() return c.composeWithGoal(text, source, includeHookContext, goal, goalStatus, goalResearchMode, autoResearchTaskID) }

Compose(text) 是公开入口,它调 compose,compose 拿一份 goal 快照后调 composeWithGoal。注意 textsource 都是用户的原始文本——为什么要分两个参数?因为后面有些处理(如语言偏好)要看"原始 source",而 text 可能已经被前面的步骤改过。includeHookContext 控制"要不要把 hook 上下文和 auto-recall 也带上"——合成恢复 turn 会传 false(下一节讲为什么)。

composeWithGoal 是真正的拼装逻辑,逐段拆:

段一:加锁取运行时模式

c.mu.Lock() plan := c.planMode responseLanguage := c.responseLanguage reasoningLanguage := c.reasoningLanguage c.mu.Unlock() notes := c.memory.drainPending()

加锁把 planMode、响应语言、推理语言三个运行时模式取出来(这三个可能被别的 goroutine 改,所以要加锁)。然后 c.memory.drainPending()——取出并清空"待处理的记忆更新队列"。这个队列是上一节/第 5 章 03 节提到的"remember 工具或 #note 写入后,排进队列等本 turn 带给模型"的 turn-tail 队列。drainPending 取出来后队列就空了,下次同样的更新不会再带。

段二:Goal 运行时块(前缀追加)

if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning { prefix := activeGoalBlock(goal, goalResearchMode) if runtime := c.autoResearchRuntimeBlock(autoResearchTaskID); runtime != "" { prefix += "\n\n" + runtime } text = prefix + "\n\n" + text }

如果当前有个 Goal 在跑,把 Goal 的运行时块拼到 text 前面。注意这里的 prefix 是个局部变量名,指"拼到用户文本前面",不是系统提示前缀——它仍然是本轮用户消息的一部分,只是位置在用户原话之前。Goal 块告诉模型"你正在为这个目标工作"。auto-research 的运行时块同理。

段三:Plan 模式标记

if plan { text = PlanModeMarker + "\n\n" + text }

如果处于 Plan 模式,在最前面加 PlanModeMarker。这个标记是个 cache-frozen 的固定字符串(改它会破坏 steer replay 匹配和前缀稳定性)。

段四:语言偏好(包裹而非追加)

text = agent.WithResponseLanguage(text, responseLanguage) text = agent.WithReasoningLanguageForSource(text, reasoningLanguage, source)

这两行把"响应语言""推理语言"偏好裹进 text。注意是"包裹"(在 text 周围加指令),不是单独追加——因为语言偏好要紧贴用户原话才有效。ForSource 版本看的是原始 source 文本(判断用户是不是已经用了某种语言),避免"用户用中文问,你还在那强调用中文回答"的冗余。

段五:记忆更新(尾部追加,核心!)

// Memory added mid-session rides the turn (never the cached system prefix), // so it takes effect now without invalidating the prompt cache. It folds into // the system prefix on the next session, where it costs nothing per turn. if len(notes) > 0 { var b strings.Builder b.WriteString("<memory-update>\n") b.WriteString("The following project-memory changes were just made and apply from now on:\n") for _, n := range notes { b.WriteString("- " + n + "\n") } b.WriteString("</memory-update>\n\n") text = b.String() + text }

这段是 ride the turn tail 的灵魂,注释本身就是契约。逐句翻译注释:"mid-session 加的记忆骑在本 turn(绝不进缓存的系统前缀),所以它立即生效而不让 prompt cache 失效。它在下次会话才折进系统前缀,在那里每 turn 零成本。"

机制:把 drainPending 拿到的 notes 包进一个 <memory-update> 块,prepend 到 text。这样:

  • 本 turn:模型看到 <memory-update>(在 turn tail),知道"从现在起这些记忆生效"。
  • 本 turn 的代价:这个块要算 token,但只算一次。
  • 下 turn 起:这个 <memory-update> 块成了"上轮用户消息的一部分",位于前缀之后,前缀字节没动,缓存照常命中。
  • 下次会话:remember 写的事实经 MEMORY.md 索引重建,折进系统前缀(boot 装配时),从此每 turn 零成本(命中缓存,只传 token 不算 KV)。

这就是"this turn tail、next session prefix"的两阶段策略:mid-session 改动先以 tail 形式立即生效(不破坏缓存),下次会话才并进前缀(永久免费)。完美地平衡了"立即生效"和"缓存稳定"两个看似矛盾的需求。

段六:后台任务完成通知(同样 tail)

// Background jobs that finished since the last turn ride the turn too, so the // model learns of completions even though the user-facing notices don't reach // its context. Like memory, this never touches the cache-stable prefix. if c.jobs != nil { if note := c.jobs.DrainCompletedNoteForSession(c.parentSessionID()); note != "" { text = "<background-jobs>\n" + note + "\n</background-jobs>\n\n" + text } }

注释同样点明:"自上 turn 以来完成的后台任务也骑在本 turn,让模型知道完成情况,哪怕面向用户的完成通知没进它的上下文。和记忆一样,绝不碰缓存稳定前缀。" 机制和记忆更新一模一样:取完成通知、包进 <background-jobs> 块、prepend 到 text。

段七:hook 上下文 + auto-recall(仅真实用户 turn)

if includeHookContext { if block := c.drainHookContextBlock(); block != "" { text = block + "\n\n" + text } // Relevant facts ride only the real user-turn tail. This preserves the // stable system/tool prefix and keeps synthetic recovery turns free of // accidental recall. A just-written fact already arrives in memory-update. if len(notes) == 0 && !c.ablation.Off(ablation.Retrieval) { if block := c.memory.recall(source).Block(); block != "" { text = strings.TrimRight(text, "\n") + "\n\n" + block } } else if len(notes) > 0 { c.memory.recordRecall(memory.RecallResult{ Query: strings.TrimSpace(source), Suppressed: "memory update already supplies the new fact", }) } }

这段最精巧,几个细节。

第一,hook 上下文只在 includeHookContext 为 true 时带。比如 SessionStart hook 注入的上下文,通过 drainHookContextBlock 取出 prepend。合成 turn(如自动恢复)不带,避免污染。

第二,auto-recall(自动召回)只骑真实用户 turn 的 tail。注释直接说:"相关事实只骑真实用户 turn 的 tail。这保持了稳定的 system/tool 前缀,并让合成恢复 turn 免于意外召回。" 为什么合成 turn 不能召回?因为合成恢复 turn(比如流断了自动重试)的"query"不是真实用户意图,拿它去召回事实会把不相关的东西塞进上下文,反而干扰恢复。

第三,如果本轮已经有 memory-update,就不再 recall。注释:"A just-written fact already arrives in memory-update."(刚写的事实已经在 memory-update 里了。)这时再 recall 同一个事实等于重复。代码进 else if len(notes) > 0 分支,调 recordRecall 记一条 "Suppressed: memory update already supplies the new fact"——注意是记录这次召回决策(供 /memory recall 诊断),而不是真的去召回。这种"显式记录抑制原因"的设计,让召回行为完全可观测。

第四,recall 的 block 追加到 text 尾部(text + "\n\n" + block),而前面的 memory-update/background-jobs 是 prepend 到头部。这个位置差异是有意的:memory-update 是"从现在起生效的指令",放前面让模型先建立认知;recall 的事实是"可能相关的背景",放后面作为低权威补充。SESSION_MEMORY_RETRIEVAL.md 也强调召回的事实是"bounded, low-authority suffix"(有界、低权威后缀),它"不能覆盖当前请求或 standing instructions"。

段八:return

return text

最终返回的 text,是"原始用户文本 + 一堆 tail 块"的拼装结果。这个 text 作为本轮用户消息追加到 Session.Messages,然后整体发给 Provider。前缀(system + 历史 messages 的前 N-1 条)字节不变,只有最后这条用户消息是新的——这就是 prefix cache 命中的保证。

三、为什么是"尾部追加"而不是"改前缀":代价对比

把两种做法的代价摆在一起对比,你才会理解 ride the turn tail 的价值。

做法 A(常见做法):mid-session 改前缀。 用户加了条记忆,直接改系统提示。后果:前缀字节变 → DeepSeek prefix cache miss → 服务端全量重算前缀所有 token 的 KV → 延迟和成本飙升 → 之后每 turn 都要重算(直到前缀再次稳定,但每加一条记忆就再 miss 一次)。在一个反复加记忆的长会话里,这是灾难。

做法 B(Reasonix 做法:ride the turn tail): 用户加了条记忆,Compose 把它包成 <memory-update> 追加到本轮用户消息尾部。后果:前缀字节不变 → prefix cache 命中 → 只算尾部新 token(几十到几百 token)→ 延迟和成本几乎不变。记忆立即生效(本 turn 模型就看到了),而且下次会话它折进前缀后永久免费。

代价差别是数量级的。这就是 Reasonix 为什么把"ride the turn tail"写进宪法、用 Compose 严格执行的原因——它把"前缀稳定"这个抽象契约,落成了一段具体的、可读的、每行都有理由的字符串拼装代码。

💡 契约要点(本节核心):Compose 的每一段拼装都不是随便 prepend/append,而是基于一个统一原则——新信息进 turn tail,前缀字节不动。memory-update / background-jobs / hook-context 进 tail 头部(高权威,先建立认知);recall 的事实进 tail 尾部(低权威后缀);Goal/Plan 标记也进 tail(只是位置在用户原话前面)。读 composeWithGoal 时,把每一段都问一遍"这段如果改成改前缀会怎样",你就懂了为什么是 tail。

四、turn-tail 队列:memory.go 的双锁设计

Compose 里那个 c.memory.drainPending() 背后是一套精心设计的队列。internal/control/memory.go 的注释把设计意图讲得很清楚:

// turn-tail notes, and the serialization of memory writes — behind its own locks // ... discovered snapshot in and queue the turn-tail note — so a write never holds a // lock across a filesystem walk. A turn-tail note is queued for each write so the

要点:memory 写入和 turn-tail 队列用各自的锁。一次 remember 写入,要先把文件系统走一遍(walk)发现 snapshot——这个 walk 很慢,绝不能持着锁走。所以设计是:walk 时不持锁,walk 完了用很短的临界区把新 snapshot 装进去、把 turn-tail note 排进队列。每次写都排一条 turn-tail note,Compose 的 drainPending 把它们取出来带给模型。

这套设计有两个不变量:(1) 写入永远不持锁跨文件系统 walk(否则会阻塞别的写入和 Compose);(2) 每次写都有一条 turn-tail note,保证 Compose 不会"漏掉"某次记忆更新。drainPending returns and clears the queued turn-tail notes, for Compose to fold——取出并清空,给 Compose 折叠进本轮。

这也解释了为什么 remember 写入"立即生效而不破坏缓存":写入本身改的是磁盘文件(MEMORY.md 索引、frontmatter 事实文件),不碰内存里的系统提示;系统提示的前缀只在下次会话 boot 时才从这些文件重新装配。本 turn 的"立即生效"靠的是 turn-tail note,完全绕开了前缀。

五、turn tail 的边界:何时不得不 compact

ride the turn tail 不是万能的。它解决的是"mid-session 加新信息"的问题,但解决不了"上下文整体太长"的问题。当对话历史 + 工具输出累积到接近上下文窗口时,追加 tail 也救不了——总长度本来就超了。这时候必须 compact(压缩),把旧对话摘要掉,腾出空间。

compact 是一个受控的 cache reset:它必然改变 provider 看到的消息字节(摘要替换了原文),所以必然触发一次 prefix cache miss。但 Reasonix 把这个代价控制到最小:

  • compact 是低频操作(只在上下文接近窗口时触发,不是每 turn)。
  • compact 后新的前缀(摘要 + 保留的近期 tail)会重新稳定,后续 turn 又能命中缓存。
  • compact 有严格的"目标 tail 预算"(下一节讲 defaultTailTokens = 16384),保证压缩后留下足够的近期上下文。

所以 turn tail 和 compact 是分工的:turn tail 处理"新信息增量"(高频,绝不破坏缓存);compact 处理"总量超限"(低频,接受一次受控 reset)。两者共同保证:长会话里绝大多数 turn 都命中缓存(只算增量),只在不得不压缩时才付一次全量重算的代价。

另外还有一种"轻量级"的 tail 边界处理:snip/prune(下一节主角)。当某个工具输出(比如一大段 bash log)膨胀时,先 snip(裁剪到关键行)再 prune(精简),尽量在不触发整体 compact 的前提下控制总量。因为直接 compact 摘要大段工具输出会丢失结构,且摘要文本本身不稳定(每次摘要可能略有不同),反而更容易破坏缓存。所以 Reasonix 的策略是"先 snip/prune 局部,实在不行才整体 compact"——分级处理,把 cache reset 推迟到最后一刻。

六、Compose 与 boot 的分工:谁负责前缀,谁负责 tail

把 boot(上一节)和 Compose(本节)放在一起看,前缀/tail 的分工就很清楚了:

关注点 负责人 何时动前缀
前缀装配(base + tools schema + memory 索引) boot 只在会话开始(Build)和下次会话重建时
tail 组装(memory-update / background-jobs / recall / goal / plan / 语言) control.Compose 每 turn,追加到本轮用户消息
总量控制(snip/prune/compact) agent(compact.go/prune.go) 接近窗口时,受控 reset

三者各司其职:boot 保证前缀"装配出来就字节稳定";Compose 保证"每 turn 的新信息进 tail 不碰前缀";agent 的压缩逻辑保证"总量超限时受控重置"。这三层加起来,就是 Reasonix 在长会话里压低 token 成本的完整机制——把 DeepSeek 的 prefix cache 物理特性,用代码层层守护成产品优势。

本节要点回顾

  1. ride the turn tail 的含义:所有新信息追加到本轮用户消息尾部,绝不碰系统提示前缀,让 prefix cache 每 turn 命中。
  2. composeWithGoal 八段:加锁取模式 → Goal 块(前置)→ Plan 标记(前置)→ 语言偏好(包裹)→ memory-update(前置 tail)→ background-jobs(前置 tail)→ hook-context + auto-recall(尾部 tail)→ return。
  3. memory-update 的两阶段:本 turn 以 tail 立即生效(不破坏缓存),下次会话折进前缀永久免费——平衡"立即生效"与"缓存稳定"。
  4. auto-recall 只骑真实用户 turn:合成恢复 turn 不召回(避免意外污染);本轮已有 memory-update 时抑制 recall 并记录原因。
  5. recall 块在 tail 尾部(低权威后缀),memory-update 在 tail 头部(高权威,先建立认知)——位置差异是有意的。
  6. 代价对比:mid-session 改前缀(全量重算,每 turn 重复)vs ride the turn tail(只算尾部新 token,数量级优势)。
  7. memory.go 双锁设计:写入和 turn-tail 队列各自锁,walk 不持锁,每次写排一条 note 保证不漏。
  8. turn tail 的边界:处理"新信息增量"(高频);总量超限靠 compact(低频受控 reset)+ snip/prune(局部先裁)分级处理。三者分工:boot 装配前缀、Compose 组装 tail、agent 控制总量。

下一节是高潮的收尾:compact / snip / prune 与 Context Engine v2。我们看 Reasonix 在"不得不动前缀"时如何把代价降到最低——先 snip 裁剪工具输出关键行、再 prune 精简、最后才 compact 摘要;cache_shape 如何维护"哪些在缓存稳定区、哪些被压缩"的形态诊断;以及 Context Engine v2 的分层记忆检索(session/project/global)如何把"召回"做成系统化的能力。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U