Files
PolyLoop/research-wiki/design/0010-context-assembly.md
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

190 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`(贴进去的正文)。
`0003``0006` 里管这类东西叫「Skill 条目」,某个下游的代码里叫「工件」,**三个说法指的
是同一样东西**——本文一律用「注入」。
**通道** 是注入的分组键。`RunRequest.injections` 是一个「通道名 → 若干条注入」的映射;库不
解释通道名,只按键分组贴。现在只有一个通道在用,做成映射是因为将来多一个通道是加一个键、
不是改类型。
**决策解释接缝**(下文简称解释器)把一次模型回复解释成三分支之一:动作、最终回答、无效决策。
它同时交回一段 `history_text`——**这一步回填进对话历史的那段文本,可以与模型原文不同**。
**合成观察** 是库自己写、回填给模型看的几段固定文本,挂在 `AgentDefinition` 上。动作被拒绝、
环境故障、模型调用失败三档用它,因为那三档没有真的环境输出可回填(`0007` 决策二)。
**四者同源**`scope.md` 定死的一条要求:工具的注册、模型可见 schema 生成、存在性与参数
校验、分发,四件事必须由同一个**工具注册表**实例驱动,否则会漂移成「模型看见一个已经删掉的
工具」。
**pi**`reference/` 下六个参考仓库之一,一个 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/` 里。