跳转至

06 · Projection 与 Prompt:从日志得到不可变的下一次请求

这一章把三个常被合称“上下文”的东西分开:模型消息 surface、领域状态 projection、以及本步骤尚未提交的 PromptAssembly。掌握这一区分以后,才能写出可恢复、可审计的自己的 Agent。

6.1 surface 并非原始 event 数组

surface 是一列能产生消息的 event seq:system/developer/user/assistant/tool-result。turn、request/header、attempt 等 log-only 记录不在列中。append 把新 seq 放到尾部;replace 将已有 surface 两个 endpoint 所夹的节点换成新的 seq。endpoint 是当前 surface 中的身份,不是直接数组下标,也不要求中间原始 seq 连续。变换计划。

例:日志里 seq 2、4、7 是消息,3、5、6 是边界或元数据。新的 seq 8 声明 replace(2,4),则 surface 从 [2,4,7] 变为 [8,7],日志仍有 0…8。sourceEventSeqs 必须包含每个实际被覆盖的旧 surface node,并且不能重复、不能为空或引用未来。tool/result replace 只能改一个 current tool/result 的 content,其他 data 和 message identity 要保持;node 0 若为 system/message,仅允许 system/message 精确覆盖这个头。替换限制。

flowchart TB E[Append-only events] --> F[foldSurface 或 SurfaceManager] F --> N[当前有序 surface nodes] F --> X[plugin-owned projectedMessages] N --> D[deriveEventMessage] X --> D D --> M[冻结 Message 对象与新 messages 数组]

人类 transcript 需要原始 append-origin 事件,不能用 current surface,否则 compaction 会抹掉已展示对话。模型上下文与人类历史共用事件事实,却需要不同投影。

6.2 SurfaceManager 先验证计划,再提交

validateNext 先折叠已有 delta,计算 expected seq,并将 candidate 与 plan 暂存;它不先改当前 nodes。log.push 成功后 _processDelta 发现恰好是该候选对象和 seq,复用验证过的 plan。重放或不同 candidate 走同一个 planSurfaceEvent 算法,所以 live append 与 offline fold 不应有两套替换语义。增量管理器。

replace 增 replaceGeneration 和 contentGeneration;pure message projection 只增 contentGeneration;普通 tail append 不增这两个 generation。消息缓存可以只扩新 nodes,已有内容改写才 rebuild。generation 不是事件总数。

6.3 deriveMessages 逐句理解

Session.deriveMessages 取 surface.nodes/contentGeneration;generation 与缓存不同,清空 derived 数组和已投影节点计数;只对未处理 nodes 调用 deriveEventMessage;null 消息不加入;更新 derivedNodes;最后返回 fresh [...derived]。数组永远是新快照,消息对象可共享深冻结身份。方法全文。

deriveEventMessage 先查 projectedMessages map,再按 event type 取消息:user/message 原样返回,system/developer/assistant 若空 content 则 null,tool/result 返回 tool message,attempt/其他事件 null。这里不会给所有注入 user message 再套一个统一 <context>;内容 framing 属于生产者,source/metadata 保留解释来源。纯投影。

6.4 两种 projection 的边界

类型 注册服务 输出 会改变模型消息吗
SessionMessageProjection ctx.sessions.registerMessageProjection 旧 seq→不可变 message 更新 会,必须由 durable event 驱动
ProjectionDefinition ctx.sessionProjections.register 一个 typed state + 可选 wire view 不直接改变 model surface

前者保持 message identity 与 node membership,用于图片 offload 等内容改写;project 必须先验证完整 durable decision,不能原地改 input。未知 required message-projection event 没对应解释器时明确失败;已用解释器移除后 _assertProjections 拒绝后续 derive。reader 只加载 core Session 不足以复原所有插件改写。消息解释器契约。

后者用于 inbox、goal、turnBoundary、team、UI 状态。它的 apply 是同步 pure transition,不相关事件必须返回同一 state reference。wire.view 也要复用对象身份才能避免无意义发布;Object.is 是 change gate,不是 JSON deep comparison。领域 registry 及类型。

6.5 领域投影的 drive、watermark 与恢复

registry 订阅 session/event 一次,每个 accepted event 驱动所有注册单位。cell 保存 state、observedSeq、前后 views。新 key 或老 Session 首次触碰会从 init 折 full log;已有 cell 补连续 tail。即便 state 不变也前进 watermark,snapshot 的 asOfSeq 因而能代表一致 cut。

stateOf 服务端读 state;snapshot 强制 materialize 全部 registered cell,schema 验证 wire view;cachedSnapshot 不补日志,只给已有 cache 的最低共同 watermark,因此只是提示,不是完整 baseline。

同一个 key 可能由多个 Agent preset 注册,refs 避免一个 Agent 卸载把所有 Session 的 key 删除。当前实现只比较 stateVersion:同 key 不同版本会拒绝;版本相同则增加 refs,继续使用第一次注册的 definition,不比较后续 definition 的函数、schema 或对象身份。注册方必须自行保证同 key/版本的语义一致,不能指望框架检出同版本冲突。最后 registrant 移除后 snapshot 表示 capability absence。registry 注册与读。

checkpoint 返回 (key→{ver,seq,val}),val structuredClone,不能借出 live mutable state。restoreFloor 不是总取 watermark+1:它取最低需要起点再退一条 anchor,以确认 stored log 没在 crash repair 后缩短。版本错、watermark 超 log end、缺 row 等都使 row 无效;若当前只有 baseSeq>0 的 suffix,不能从 init 假装已看过前缀,抛错要求从 0 重读。checkpoint/restore。

所以 stateVersion 是投影缓存算法和字段版本,不是 Session structural version;缓存永远不是真实日志的替代品。

6.6 SystemPrompt 的全部贡献类型

SystemPrompt 管全局和 scoped layer;同名 section/context/variable 由最近 scope 遮蔽,tools providers 则合并贡献。section 有确定 order,context 有 order,variable 名必须 [a-z][a-z0-9_]*。scope suppressRuntimeContext 屏蔽动态快照,不会撤销 enforce policy 的服务本身。注册函数。

assemble 顺序:

  1. 找 scope chain,判断 global/ancestor suppressor。
  2. 全局 variables 后远到近 scoped variables,最近值优先。
  3. 按名字 merge sections/contexts;收全局和匹配 scoped tools providers。
  4. 工具 parameters structuredClone,仅投影 name/description/parameters/deferLoading。
  5. section order 再 code-unit name 排序;tool 默认按名字排序,显式 toolOrder 必须含 <unlisted-tools> 且不能未知名称。
  6. 求 section/context 的动态 text,形成 {sections,contexts,tools,variables}。
  7. system-prompt/assemble waterfall 改写 assembly。
  8. complete section 若有则最后恢复为唯一 sections,suppressor 若有则最后恢复 contexts=[],不能被 waterfall 意外取消。

complete section 同时激活超过一个会报错;未知配置工具名在加载时未必能确定,所以 assemble 才报。工具 schema 顺序确定能稳定 header equality 和 provider cache 前缀,但不保证网络缓存一定命中。assemble 全文。

6.7 renderPrompt 与严格变量

section.text 为函数则先在 assembly 求值,renderPrompt 再插值。interpolate:false 的 section 保留字面文本;其他 section 遇到 unknown/undefined/malformed {{name}} 就错误,空 section 被丢弃,以双换行连接。不闭合的 lone {{ 且后面无 }} 当字面 prose;替换出来的字符串不会再次扫描。不能把 unknown variable 自动变空,以免关键身份/目录配置静默丢失。渲染实现。

context 使用 renderContextSections 一次插值、保留 named sections,再 joinContextSections 包含“本快照覆盖此前快照”的 framing;这是 user-role input,不是系统头的一部分。

6.8 RuntimeContextProjection:变化才发一条候选

恢复时从 newest event 向前找到 retained runtime-context user/message 并检查其是否还在 surface。新 assembly 的全部 rendered context 与 retained text 相同则不发;首次为空且没有历史则不发;从非空变空发明确 CLEARED 文本;改了才返回一个尚未提交的 UserMessage。它的 source 是 typed object:sections.length > 0 时为 {kind: 'runtime-context', form: 'snapshot', sections},保存贡献归属;sections 为空时仅为 {kind: 'runtime-context'},清空 marker 不带已经消失的贡献。这不是字符串 source,也不是每条更新都有 form/sections。

只有 loop 入步和真实 route 准备成功后,候选才在 user/message 中提交。projection.project 本身不是 append。若消息被 replacement shadow,事件监听把 retained 置 null,下次可重新发当前快照。完整实现。

6.9 系统提示词:由实际 prepared route 决定承认方式

SystemPromptProjection.project(rendered,{inHistory,startsSeries}) 找当前所有 surviving system nodes。一个都没有时,即使 rendered='' 也预留第一个 system 节点。空节点不派生 wire message,但保证系统头的位置明确。

条件 操作
支持 in-history,系列继续,rendered 非空且与最新有效 prompt 不同 append 新 system/message 到历史后面
incapable route、显式新系列、surface content 改写或不支持 tool update 的工具变化 将非空 tail system nodes 清空;必要时 replace head
rendered 为空 清空所有有效旧 system text,不能恢复旧指令
当前有效文本未变 无需新事件(但 consolidation 还可能清 tail)

这里用本 attempt 的 prepareCall 返回能力,而不是上一 request/context 快照。模型/工具切换能改变 route 能力,旧 metadata 不能做本次 prompt admission 的依据。系统投影全部分支。

6.10 request/header、developer updates、toolHistory

header 只有 config、adapter-default marker、tools;system prompt 已经在 surface,header.system 被明确拒绝。canonicalHeader 将空可选项省略;headerEquals config 按字段、tools 按顺序 JSON schema 比较。

buildRequest 在首次、resume、header change、series break 时记完整 header;只换 prompt 并在 capable history append 时可能无需新 header。增加/移除 tool 名生成 developer message;addition 指向当前 headerSeq,历史定义从该 header 读取,不在 tool-addition 内重复 inline schema。header 与 developer 更新。

ToolHistoryProjection 保存系列 baseline、历史定义和 developer additions。删掉工具不删除已声明历史定义;名字重新注册不同定义会重开系列以丢弃旧声明前缀复用。某些旧 Session 只有 header 无 matching updates 时 snapshot fallback 到 active tools。toolHistory 是 provider projection 的输入,和 request.tools 当前集合不能互换。工具历史完整折叠。

6.11 retry:冻结每个 request,不是永久重用一个 request

一个 step 的 assembly 和 admitted messages 只决定一次;retry 返回后不重复 assembly/pre-step,也不重加 users。但 每次 attempt 都重新 prepareRequest、reconcile system、buildRequest,可以改变路由和 adapter defaults;header/context 只在有变化时新记录。

buildRequest 深冻结 canonical header、所有 derived message 身份(WeakSet 避免同 Agent 重复 freeze)、fresh messages 数组和 envelope;AbortSignal 保持 live。不会为了 freeze message 把借来的 restored event wrapper 一并冻结。prepared adapter 一次绑定跨 default resolution、header logging 和实际 dispatch,避免同 attempt 中 provider 替换造成 config 来自 A、调用却去 B。准备/冻结边界。

sequenceDiagram participant P as pre-step participant S as Session participant R as attempt participant M as Prepared adapter P->>R: 一份 assembly 和 messages loop 每次 attempt R->>M: prepareCall 路由及默认值 R->>S: 系统更新;仅首尝试提交 users R->>S: header/context;deriveMessages R->>M: 冻结 envelope 与消息,signal live M-->>R: success 或 terminal error end

自己的插件若要额外 model-facing 内容,不要在 llm/stream 中偷偷 push 一条未记录 message。使用 logged user input 或专门的 pure message projection;如果必须改变消息系列,保留或明确设置 startsRequestSeries。实验应比较实际 mock adapter 收到的 message 与同 prefix 的日志重建结果。