Files
PolyLoop/research-wiki/design/0010-context-assembly.md
T
iomgaa c5c1e68706 docs(design): 按两轮硕士生冷读改 0009 与 0010
0009 最实的一条是文档与代码对不上:文档说那条扫描测试断言 __dataclass_params__.kw_only,
而代码实际查的是构造签名。冷读的人正确地指出前者守的范围更窄——哨兵写法 KW_ONLY 达成了同样
的效果但那个标志是假(会误杀),而 kw_only=True 的类里用 field(kw_only=False) 开口子那个
标志仍然是真(会漏掉)。文档改成描述实际做法并写清楚这条理由。另修:「长六个字符」实际是
五个;补上两种写法的区别、Pydantic AI 那条「超过一个位置参数」与本文「一个都不许」的差别
来自处境不同、规则作用域只管数据类(NamedTuple 与 pydantic 绕得过,是已知边界)、
以及 §1.3「新增字段必带默认值」为什么不受影响。

0010 最实的一条是论据自相矛盾:决策二拿「跑通了的下游逐字一致」当段序依据,决策三又说那个
槽位在那边至今没实现、收到非空注入直接抛错。改成说准——它证明的是有人设计装配顺序时把槽位
放在了这里,不是这个位置在真实提示词里验证过。另补一节「读本文需要的几个名字」(注入/通道/
解释器/合成观察/四者同源/pi 全是首次出现即使用,而且「Skill 条目」「工件」「Injection」三个
说法指同一样东西却没有一处对上);承认库实际还定了消息边界的粒度,超出了「只管顺序槽位规模」
那句话,并给出这条线画在哪的判据;说明「变化频率」指的是跨运行而不是一次运行内部;补上规模
量出来给谁用、模板为什么在构造请求时就校验、以及为什么不回填解释出来的动作。
2026-08-10 03:15:46 -04:00

14 KiB
Raw Permalink Blame History

Design 0010 · 上下文装配:段序、注入槽、观察回填

日期 2026-08-10 · 状态 已接受(2026-08-10 项目负责人确认)

取代 0006-public-names-and-signatures.md 决策三里 RunRequest.tool_section_template 那一 行——那个字段删掉,库不渲染工具段。理由见决策一。

补充 0003-public-api-shape.md 决策五。那一条写着「归库的只有段顺序与注入槽的位置,那是 纯函数不是扩展点」,但那个顺序到底是什么,从没有一处写过polyloop/_assembly/ 因此写不 出来。本文把它写出来。

触及 ../../src/polyloop/_assembly/

读本文需要的几个名字

它们都定在别处,这里各给一句,免得读到一半得去翻另外几份文档。

注入 / Injection 是一条要贴进上下文的额外内容,本库只负责贴和记录贴了什么,不负责 生成、评测、挑选。它有两个字段:entry_id(进轨迹的标识)与 content(贴进去的正文)。 00030006 里管这类东西叫「Skill 条目」,某个下游的代码里叫「工件」,三个说法指的 是同一样东西——本文一律用「注入」。

通道 是注入的分组键。RunRequest.injections 是一个「通道名 → 若干条注入」的映射;库不 解释通道名,只按键分组贴。现在只有一个通道在用,做成映射是因为将来多一个通道是加一个键、 不是改类型。

决策解释接缝(下文简称解释器)把一次模型回复解释成三分支之一:动作、最终回答、无效决策。 它同时交回一段 history_text——这一步回填进对话历史的那段文本,可以与模型原文不同

合成观察 是库自己写、回填给模型看的几段固定文本,挂在 AgentDefinition 上。动作被拒绝、 环境故障、模型调用失败三档用它,因为那三档没有真的环境输出可回填(0007 决策二)。

四者同源scope.md 定死的一条要求:工具的注册、模型可见 schema 生成、存在性与参数 校验、分发,四件事必须由同一个工具注册表实例驱动,否则会漂移成「模型看见一个已经删掉的 工具」。

pireference/ 下六个参考仓库之一,一个 TypeScript 写的 agent 框架。按 CLAUDE.md §0 它是参考资料不是权威,本文引它只作旁证。

装配是什么

每一步调模型之前,库要把一堆片段拼成一个消息序列交给模型调用接缝。片段有四类:这次运行从头 到尾不变的那段(角色说明、示例演示)、这次要贴进去的 Skill 条目、这一个目标特有的那段(题面)、 以及前面每一步的「模型说了什么 + 环境返回了什么」。

库不决定这些片段里写什么,那是渲染格式,按 ../explanation/scope.md 的排除条款留在项目侧 ——有下游要系统地改动这些文本并观测它带来的差异,库把它写死,那段文本就成了库定的变量,而且 逃出了项目自己的参数快照。

库决定的是拼接顺序、槽位在哪、消息边界的粒度、以及规模怎么量。 「消息边界」这一项要单独 点出来:决策三让每条注入各占一条消息、决策四让每个已完成的步占两条,这都是库在定形状。它和 「片段里写什么」的分界线是这个决定会不会随项目而变——分隔符、标题、格式模板每家都不同, 而「一条注入是不是独立成一条消息」不改变任何一家看到的文字内容,只改变它落在哪条消息里。 把边界也交给项目的话,库就没法保证空注入等同了(决策三),因为那时连「有没有多出一条消息」 都由项目说了算。

决策一:库不渲染工具段,tool_section_template 删掉

0006 决策三给 RunRequest 加了一个 tool_section_template,意思是「调用方给一个带占位符的 模板,库把工具清单填进去」。这个字段没有消费者,而且照它做会弄坏一个下游。

两个真实消费者都是项目侧自己拼的。 dissect 的工具清单是它提示词模板里的一个变量,位置在 run 级前缀第 7 个消息块,而且被伪装成示例演示里某一次命令执行的输出Output: 加一个代码 围栏)——那是 ReAct 那套提示工程的常见做法,让模型觉得自己刚查过一遍工具文档。对它来说工具 清单就是 run 级上下文的一部分,不是一个能单独拎出来的段。GovDoc 的循环只发两条消息、都是上层 传进来的字符串,它包里那个 render_tool_docs() 全仓零生产调用方。

留着这个字段的失败场景很具体。 字段的存在本身就是一条指示:迁移的人看到「工具清单贴进 提示词的格式模板」会去填它,填完之后那份提示词里有两份工具清单——一份在项目自己的模板里, 一份是库新插进去的。而那个下游的迁移验收标准是「轨迹与迁移前逐字段可比」,提示词一变数据就 不可比了,而且不会报错,只表现成「新跑的这批分数有点不一样」,正好和实验想测的效应长得 一样。

参考框架 pi 的内核也不渲染。 它把工具原样交给 provider 层走模型 API 的原生 tools 参数, packages/agent 里没有任何把工具变成文本的代码。它的应用层 packages/coding-agent 确实有一段 Available tools:,但那一段的每一行来自 promptSnippet——一个和 description 分开的字段, 没提供 snippet 的工具照样能调用、只是不出现在清单上,还有一条测试专门断言 description 不许 漏进提示词。这说明一件事:就算要渲染工具段,那段文字也不该是从校验用的 schema 生成的。 tool_section_template 的设计恰恰是「库拿 schema_for_model() 填进你的模板」。

「四者同源」不受影响。 注册、模型可见 schema 生成、存在性与参数校验、分发仍然由同一个 注册表实例驱动:schema_for_model() 这个方法还在,项目想自己渲染工具段就调它,拿到的和校验、 分发用的是同一份。

代价:哪天真有项目希望库来渲染,得把字段加回来。 那时加一个带默认值的字段是兼容变更, 方向是便宜的那一边。

决策二:段序

run 级片段 → 注入槽 → 目标级片段 → 逐步的「模型输出 / 观察」交替

照变化频率从低到高排。 模型供应商按前缀缓存计费,把逐次变化的东西排到前面会让缓存静默 失效,而多付的幅度随注入内容的规模变化——于是缓存伪影会精确地伪装成下游想测的效应。

这里说的「变化」是跨运行的变化,不是一次运行内部的。 四类片段里只有历史在一次运行内部 增长,前三类在一次运行内都不变——所以排序的意义全在前缀缓存跨运行复用那一头:同一份 agent 定义跑几十次,run 级片段每次都一样,注入与目标级片段每次都不同。把不变的排在最前面,这几十 次才共享得到同一段缓存前缀。

注入槽排在两段之间,不是最前也不是最后。 排到最前会把最稳定的那段挤到后面去、白丢缓存; 排到最后会让注入内容跑到题面后面,而它通常是在给「怎么做这道题」的额外指引,位置在题面之后 读起来是反的。

那个下游的装配代码里,槽位就在这个位置run_prefix → 工件 → item_suffix)。这条旁证要 说准:它证明的是有人在设计装配顺序时把槽位放在了这里,不是「这个位置已经在真实提示词里 验证过」——那个槽位的渲染至今没有实现,它的代码在收到非空注入时直接抛错(见决策三)。所以 上面那两条理由才是这个位置的依据,这条旁证只说明我们和它想到一块去了。

库不合成任何 system 消息。 那个跑通了的下游全程没有 system 消息(它的模板只用 USER 与 ASSISTANT 两种角色,ReAct 那套惯例如此),另一个有。库自己加一条的话,前者的提示词凭空多出 一段。要 system 消息的项目把它放进自己的 run 级片段里。

决策三:注入槽——每条一条消息,正文原样,通道按名字排序

每一条 Injection 渲染成一条 USER 消息,内容就是它的 content前后不加任何标题、分隔符 或换行

为什么不把多条拼成一条。 拼就要选一个分隔符,而分隔符是渲染格式,归项目侧。一条一条发则 库一个字符都没有添。那个下游把注入的渲染形态明确标成一个实验因子、并且至今没有实现——它 的 _artifact_messages 在收到非空输入时直接抛错,docstring 写着「空输入返回空列表,此时渲染 出来的前缀与『根本没有这个槽位』逐字节相同」。库现在把这件事做完,做法必须是「不添任何东西」。

空注入等同injections 为空时槽位产出零条消息,装配结果与「根本没有这个槽位」逐字节相同。 这是 0003 点名的一条验收标准——某个下游第一阶段不注入任何东西,库要是悄悄多写一个空标题或 一个换行,那一阶段的基线就和后续阶段不可比了。

通道之间按通道名的字典序排,通道内按给定顺序。 injections 是一个映射,而映射的迭代顺序 取决于调用方怎么构造它——用推导式从一个集合建出来的话,顺序每进程都可能不同(Python 的字符串 哈希每进程随机),于是同一份配置在不同进程里渲染出不同的提示词。按名字排是确定的。

角色一律 USER 那个没有 system 消息的下游决定了这是唯一对两家都安全的选择。给 Injection 加一个角色字段是在给一个还没有消费者的需求预留结构;真需要时加一个带默认值的字段是兼容变更。

决策四:历史轮次——模型原文一条,观察一条,观察套模板

每一个已完成的步渲染成两条消息:

  • ASSISTANT,内容是那一步的 raw_output这是解释器交回来的那段文本,不一定等于模型原文 ——解释器有权改写它,比如把第一个代码围栏之后的内容整段丢掉(模型常在代码块后面编造执行 结果,留着的话下一轮它会把那段幻想当成真发生过的事)。库照 raw_output 回填,不自己截断。

    回填的是这段文本,不是解释出来的那个动作。 解释结果是库自己用的结构(工具名、参数、 或者一段代码),把它回填就等于让库替项目决定「模型在历史里看见的自己长什么样」——而那正是 渲染格式。项目要让模型看见规范化后的动作,自己在解释器里把 history_text 写成那样就行, 这条路已经开着。

  • USER,内容是 observation_template.format(observation=step.observation)

观察为什么要套模板。 环境返回的是裸文本,模型需要一个边界才知道哪里是它自己说的、哪里是 环境说的。那个边界长什么样归项目——一个下游用的是 Output:\n```\n{observation}\n```\n\n

模板必须含 {observation} 占位符,不含就报错。 不校验的话,一个写错的模板会让每一步的 观察整个消失,而模型收到的是一段固定文本、看起来完全正常——它会以为每次执行都返回了同样的 东西,然后开始瞎猜,而轨迹里那一列明明存着真的观察。

校验在构造运行请求那一刻做,装配时再查一遍。 前者是主闸:那时还没写运行开始记录、没调 模型、没花钱,报错的代价只是一次构造失败。后者是因为装配函数是纯函数、可以被单独调用,它 不该指望调用方已经查过。两处查的是同一件事,重复的代价是几微秒。

str.format 而不是别的替换方式,因为那个下游今天用的就是它,迁移时模板字符串一个字都 不用改。代价是模板里的其他花括号要转义,这一条写进那个字段的 docstring。

没有动作的步照样有观察:解析失败那一步回填的是解释器给的说明文本,模型调用失败那一步回填 的是合成观察。所以这里不需要「这一步有没有观察」的分支。

决策五:规模度量逐块问,认不得的块类型直接失败

提示词规模 = 所有消息、所有内容块的规模度量之和,单位是字符不是 token。

量出来给谁用:停止判定的 B 档拿它和 Budget.max_prompt_chars 比,超了就以 context_overflow 终止这次运行,不静默截断(0004 决策三 B)。上限由调用方按次设定,单位 同样是字符。

为什么是字符。 0003 决策六定了消息内容是内容块序列而不是裸字符串,理由之一是库不需要 内容是字符串、只需要每个块能报出一个规模度量。文本块的度量是准确的字符数。用 token 要引一个 分词器,那是第三方依赖,而且不同模型的分词器不同——一个跨模型可比的上限用 token 量反而不可比。

认不得的块类型直接抛错,不当成 0。 现在只有文本块,将来加图片块时如果漏了对应的度量, 当成 0 的后果是一个真的超限的提示词悄悄通过这一档,然后在模型那边报超限——那是一个来自网关 的错误,看不出是本库的规模判定漏了一类块。

留给后续的

模型原生的工具调用没有路。 ModelCall 上只有消息序列,没有 tools 字段,所以工具要让模型 看见,唯一的路是变成项目自己拼进上下文的文字。两个已知消费者都不用原生工具调用(一个的动作 协议是写在文本里的 JSON,一个是代码围栏),所以现在不缺。真出现要用原生工具调用的消费者时, ModelCall 加一个 tools 字段是兼容变更,但还要看下面那一层的共用库暴不暴露这个参数。这一条 登记在 ../migrations/ 里。