feat(_assembly): 落成段序与注入槽;design 0010 删掉工具段模板

0010 取代 0006 决策三里 tool_section_template 那一行。三条理由:两个真实消费者都是项目侧
自己把工具清单拼进上下文的(一个塞在 run 级模板里伪装成示例演示的一次执行输出,另一个的
render_tool_docs 全仓零生产调用方);留着它的话迁移的人会去填,填完提示词里有两份工具清单,
而那个下游的验收标准是轨迹逐字段可比、变了还不报错;参考框架 pi 的内核同样不渲染,它的应用层
虽有 Available tools 段,但每行来自与 description 分开的 promptSnippet 字段——说明就算要渲染,
那段文字也不该是从校验用的 schema 生成的。四者同源不受影响,schema_for_model() 还在。

段序照唯一跑通了的下游:run 级片段 → 注入槽 → 目标级片段 → 逐步的模型输出/观察交替,
按变化频率从低到高排(供应商按前缀缓存计费)。库不合成任何 system 消息——有一个下游全程
没有 system 消息,加一条它的提示词会凭空多出一段。

注入槽每条一条 USER 消息、正文原样、不加任何分隔符(分隔符是渲染格式,归项目侧),通道按
名字排序(映射的迭代顺序取决于调用方怎么构造,用集合建出来的每进程都不同)。空注入等同有
测试守着。观察模板必须含占位符,缺了就报错——不校验的话每一步的观察会整个消失,而模型收到
的是一段看起来完全正常的固定文本。规模度量逐块问,认不得的块类型直接失败而不是当成 0。

零业务假设扫描第三次抓到我自己(模块 docstring 里写了「实验因子」),已改成中性说法。
migrations/govdoc-saas.md 登记了「模型原生工具调用没有路」这个缺口。
This commit is contained in:
2026-08-10 02:48:56 -04:00
parent 7da5e07726
commit 2367d3afbc
4 changed files with 575 additions and 6 deletions
@@ -0,0 +1,134 @@
# 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/`
## 装配是什么
每一步调模型之前,库要把一堆片段拼成一个消息序列交给模型调用接缝。片段有四类:这次运行从头
到尾不变的那段(角色说明、示例演示)、这次要贴进去的 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 级片段 → 注入槽 → 目标级片段 → 逐步的「模型输出 / 观察」交替
```
**照变化频率从低到高排。** 模型供应商按前缀缓存计费,把逐次变化的东西排到前面会让缓存静默
失效,而多付的幅度随注入内容的规模变化——于是缓存伪影会精确地伪装成下游想测的效应。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` 回填,不自己截断。
- `USER`,内容是 `observation_template.format(observation=step.observation)`
**观察为什么要套模板。** 环境返回的是裸文本,模型需要一个边界才知道哪里是它自己说的、哪里是
环境说的。那个边界长什么样归项目——一个下游用的是 ``Output:\n```\n{observation}\n```\n\n``。
**模板必须含 `{observation}` 占位符,不含就在装配时直接报错。** 不校验的话,一个写错的模板会让
每一步的观察整个消失,而模型收到的是一段固定文本、看起来完全正常——它会以为每次执行都返回了
同样的东西,然后开始瞎猜,而轨迹里那一列明明存着真的观察。
**用 `str.format` 而不是别的替换方式**,因为那个下游今天用的就是它,迁移时模板字符串一个字都
不用改。代价是模板里的其他花括号要转义,这一条写进那个字段的 docstring。
**没有动作的步照样有观察**:解析失败那一步回填的是解释器给的说明文本,模型调用失败那一步回填
的是合成观察。所以这里不需要「这一步有没有观察」的分支。
## 决策五:规模度量逐块问,认不得的块类型直接失败
提示词规模 = 所有消息、所有内容块的规模度量之和,单位是**字符**不是 token。
**为什么是字符。** `0003` 决策六定了消息内容是内容块序列而不是裸字符串,理由之一是库不需要
内容是字符串、只需要每个块能报出一个规模度量。文本块的度量是准确的字符数。用 token 要引一个
分词器,那是第三方依赖,而且不同模型的分词器不同——一个跨模型可比的上限用 token 量反而不可比。
**认不得的块类型直接抛错,不当成 0。** 现在只有文本块,将来加图片块时如果漏了对应的度量,
当成 0 的后果是一个真的超限的提示词悄悄通过这一档,然后在模型那边报超限——那是一个来自网关
的错误,看不出是本库的规模判定漏了一类块。
## 留给后续的
**模型原生的工具调用没有路。** `ModelCall` 上只有消息序列,没有 tools 字段,所以工具要让模型
看见,唯一的路是变成项目自己拼进上下文的文字。两个已知消费者都不用原生工具调用(一个的动作
协议是写在文本里的 JSON,一个是代码围栏),所以现在不缺。真出现要用原生工具调用的消费者时,
`ModelCall` 加一个 tools 字段是兼容变更,但还要看下面那一层的共用库暴不暴露这个参数。这一条
登记在 `../migrations/` 里。
+7
View File
@@ -132,6 +132,13 @@ Agent 层补一层。
这些是 PolyLoop 现在答不上来的问题。答不上来不等于设计错了,但每一条都得有明确结论——
哪怕结论是「不支持,理由是什么」。
**模型原生的工具调用没有路。** PolyLoop 的模型调用入参上只有消息序列,没有 tools 字段,
所以工具要让模型看见,唯一的路是变成项目自己拼进上下文的文字
`../design/0010-context-assembly.md` 决策一与文末)。生产实现今天的动作协议是写在文本里的
JSON,不用原生工具调用,所以现在不缺。哪天要换成原生工具调用,PolyLoop 的模型调用入参要加一
个 tools 字段——那是兼容变更,但还要看下面那一层的共用库暴不暴露这个参数,以及原生工具调用的
返回怎么进决策解释接缝那三个分支。**这条要在换协议之前定,不能边换边定。**
**审计出口与事件流的关系。** 生产实现的审计出口是一个「发一条带类型和载荷的事件」的接口,
而 PolyLoop 的方向是「观察走事件流、干预走具名回调」。事件流能不能覆盖审计的需求,取决于
事件里带不带原始响应——审计纪律要求原始输出和修复后的输出都留痕。事件集与回调清单要独立