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
+174 -6
View File
@@ -1,9 +1,177 @@
"""段序、注入槽、规模度量。内部模块
"""上下文装配:段序、注入槽的位置、观察回填的形态、规模度量
渲染格式不在这里:那是调用方自己要改的东西。库一旦在内部按某个条件拼出自己的文本,
那段文本就成了库替调用方定的内容,而且不会出现在调用方的参数快照里。归这里的只有段的
顺序与注入槽的位置,那是纯函数不是扩展点。
**内部模块**(下划线开头,不进 `polyloop/__init__.py`)。纯逻辑,无 I/O、无事件循环——由
`pyproject.toml` 的一条 import 契约断言(禁止 import `asyncio` 与 `pathlib`)。
**纯逻辑,不碰 I/O 与事件循环。** 机器只拦得住 `asyncio` 与 `pathlib` 这两个最常见的
入口,真正守住纯度的是这个模块的测试形态:不许用 fixture 起外部资源、不许有 `async def`。
**库不决定片段里写什么。** 那是渲染格式,按 `research-wiki/explanation/scope.md` 的排除条款
留在项目侧——有下游要系统地改动这些文本并观测它带来的差异,库把它写死,那段文本就成了库定
的变量,而且逃出了项目自己的参数快照。库决定的只有拼接顺序、槽位在哪、规模怎么量,三样都在
`research-wiki/design/0010-context-assembly.md`。
段序,照变化频率从低到高排(供应商按前缀缓存计费,逐次变化的东西排前面会让缓存静默失效):
run 级片段 → 注入槽 → 目标级片段 → 逐步的「模型输出 / 观察」交替
**库不合成任何 system 消息。** 有一个下游全程没有 system 消息(它的模板只用 USER 与 ASSISTANT
两种角色),库自己加一条的话它的提示词凭空多出一段。要 system 消息的项目把它放进自己的 run 级
片段里。
"""
from collections.abc import Mapping, Sequence
from polyloop.types import (
ContentBlock,
Context,
Injection,
Message,
Role,
StepRecord,
TextBlock,
)
#: 观察模板里那个占位符的名字。模板必须含它。
OBSERVATION_PLACEHOLDER = "observation"
class AssemblyError(ValueError):
"""装配不出消息序列:模板不合法,或者撞上认不得的内容块类型。"""
def _block_chars(block: ContentBlock) -> int:
"""一个内容块的规模度量。
**认不得的块类型直接抛错,不当成 0。** 现在只有文本块,将来加图片块时如果漏了对应的
度量,当成 0 的后果是一个真的超限的提示词悄悄通过规模判定那一档,然后在模型那边报超限
——那是一个来自网关的错误,看不出是本库的规模判定漏了一类块。
"""
if isinstance(block, TextBlock):
return len(block.text)
raise AssemblyError(f"不认得的内容块类型:{type(block).__name__},它没有规模度量")
def prompt_chars(messages: Sequence[Message]) -> int:
"""装配出来的这一份提示词有多大,单位是字符。
**不是 token。** 用 token 要引一个分词器,那是第三方依赖,而且不同模型的分词器不同——一个
跨模型可比的上限用 token 量反而不可比。
"""
return sum(_block_chars(block) for message in messages for block in message.content)
def _text(role: Role, text: str) -> Message:
return Message(role=role, content=(TextBlock(text=text),))
def injection_messages(injections: Mapping[str, tuple[Injection, ...]]) -> tuple[Message, ...]:
"""注入槽:每条注入一条 `USER` 消息,正文原样,前后不加任何标题、分隔符或换行。
**空注入等同**:没有注入时返回空元组,装配结果与「根本没有这个槽位」逐字节相同。某个
下游第一阶段不注入任何东西,库要是悄悄多写一个空标题或一个换行,那一阶段的基线就和后续
阶段不可比了。
**不把多条拼成一条**:拼就要选一个分隔符,而分隔符是渲染格式、归项目侧。一条一条发则库
一个字符都没有添。
**通道之间按通道名的字典序排。** 映射的迭代顺序取决于调用方怎么构造它——用推导式从一个
集合建出来的话,顺序每进程都可能不同(Python 的字符串哈希每进程随机),于是同一份配置在
不同进程里渲染出不同的提示词。通道内按给定顺序。
角色一律 `USER`,因为有一个下游全程没有 system 消息。给注入加一个角色字段是在给一个还没有
消费者的需求预留结构。
"""
return tuple(
_text(Role.USER, entry.content)
for channel in sorted(injections)
for entry in injections[channel]
)
def injected_entry_ids(injections: Mapping[str, tuple[Injection, ...]]) -> tuple[str, ...]:
"""这次贴了哪几条,按与 `injection_messages` 相同的顺序。
**正文不进快照也不进轨迹,只有条目标识进。** 注入内容可能很大,进快照会让快照变成一份
数据副本;而「这次贴了哪几条」事后要查得到。
"""
return tuple(entry.entry_id for channel in sorted(injections) for entry in injections[channel])
def history_messages(steps: Sequence[StepRecord], observation_template: str) -> tuple[Message, ...]:
"""已完成的每一步渲染成两条消息:模型说了什么,环境返回了什么。
第一条是 `ASSISTANT`,内容是 `raw_output`。**那不一定等于模型原文**——解释器有权改写它,
比如把第一个代码围栏之后的内容整段丢掉(模型常在代码块后面编造执行结果,留着的话下一轮
它会把那段幻想当成真发生过的事)。库照它回填,不自己截断。
第二条是 `USER`,内容是观察套上模板。**没有动作的步照样有观察**:解析失败那一步回填的是
解释器给的说明文本,模型调用失败那一步回填的是合成观察。所以这里没有「这一步有没有观察」
的分支。
"""
check_observation_template(observation_template)
messages: list[Message] = []
for step in steps:
messages.append(_text(Role.ASSISTANT, step.raw_output))
messages.append(
_text(
Role.USER,
observation_template.format(**{OBSERVATION_PLACEHOLDER: step.observation}),
)
)
return tuple(messages)
def check_observation_template(observation_template: str) -> None:
"""模板必须含那个占位符,不含就直接报错。
不校验的话,一个写错的模板会让每一步的观察整个消失,而模型收到的是一段固定文本、看起来
完全正常——它会以为每次执行都返回了同样的东西,然后开始瞎猜,而轨迹里那一列明明存着真的
观察。
用 `str.format` 是因为某个下游今天用的就是它,迁移时模板字符串一个字都不用改。代价是模板
里的其他花括号要写成双份;下面那次试渲染会把没转义的花括号一并抓出来。
"""
if not isinstance(observation_template, str):
raise AssemblyError(f"观察模板必须是字符串,收到 {type(observation_template).__name__}")
try:
rendered = observation_template.format(**{OBSERVATION_PLACEHOLDER: "\x00"})
except (KeyError, IndexError, ValueError) as exc:
raise AssemblyError(
f"观察模板渲染不了:{exc}。模板里除 {{{OBSERVATION_PLACEHOLDER}}} 之外的花括号要写成双份"
) from exc
if "\x00" not in rendered:
raise AssemblyError(
f"观察模板里没有 {{{OBSERVATION_PLACEHOLDER}}} 占位符,"
"照它渲染的话每一步的观察会整个消失,而模型收到的是一段看起来完全正常的固定文本"
)
def assemble(
*,
context: Context,
injections: Mapping[str, tuple[Injection, ...]],
steps: Sequence[StepRecord],
observation_template: str,
) -> tuple[Message, ...]:
"""把四类片段拼成这一步要发给模型的消息序列。
注入槽排在两段之间,不是最前也不是最后:排到最前会把最稳定的那段挤到后面去、白丢前缀
缓存;排到最后会让注入内容跑到题面后面,而它通常是在给「怎么做这道题」的额外指引,位置
在题面之后读起来是反的。
"""
return (
*context.run_level,
*injection_messages(injections),
*context.goal_level,
*history_messages(steps, observation_template),
)
__all__ = [
"OBSERVATION_PLACEHOLDER",
"AssemblyError",
"assemble",
"check_observation_template",
"history_messages",
"injected_entry_ids",
"injection_messages",
"prompt_chars",
]