Files
PolyLoop/research-wiki/migrations/govdoc-saas.md
T
iomgaa c0d9d7b66e docs: 回写 CHANGELOG、GovDoc 迁移,以及压测那条过期注释
CHANGELOG 落在「未发布」段,按那一段自己的规矩不提前写版本号。写清了四条会抛 ValueError 的
情形与迁移写法,因为这是一次破坏性变更。

migrations/govdoc-saas.md 加一节讲租户命名空间怎么传,含迁完算不算数的四条判据,最终判据是
「同一段文本由两个租户各提交一次,各自拿到自己的那份输出」。参数一律指向 0017 不复述。这份
文档原来说 GovDoc 侧「还没有可迁移的东西」,现在有了第一条能逐条验的接入动作,文件头那句
状态说明跟着补了一句例外。

tools/soak/run_soak.py 那份空绑定上方的注释在复述旧转发规则,顺手改对。它给的理由本来就不准
——让绑定留空的真正原因是绑定的全部键值都进参数快照,每批都不同的值会让故障注入那一步续跑时
报一次假的参数漂移,和转发哪些键无关。三份压测绑定常量里都没有裸键,所以这次变更打不到压测。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:53:20 -04:00

229 lines
16 KiB
Markdown
Raw 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.
# 设计对齐:GovDoc-SaaS
> **更新触发点:第 2 档(同提交同改)。** 公共类型或接缝语义变更时,在同一个提交里核对
> 本文的需求条目还能不能被承载;GovDoc 侧的 agent 方案定下来时,在同一个提交里更新需求
> 来源与缺口清单。
>
> **当前状态:这不是一份迁移清单,是一份需求清单。** GovDoc-SaaS 的 agent 部分还没有可迁移
> 的东西,本文的作用是防止 PolyLoop 只按 dissect 一家的形状长。唯一已经能逐条验的接入动作是
> 租户隔离参数那一节。
PolyLoop 只有 dissect 一个硬消费者(见 `dissect.md`)。只对着一个消费者做,做出来的库会长成
那个消费者的形状,而这件事在完成之前看不出来。GovDoc 这边不做迁移验收,改做**设计级验收**:
把它真实需要的东西列成条目,逐条问 PolyLoop 能不能承载,答不上来的登记成缺口。
## GovDoc 的项目术语
这些词是 GovDoc 的,不是 PolyLoop 的。PolyLoop 自己的词表在
`../explanation/architecture.md` 第二节。
**PES**——Plan-Execute-Summarize,「计划—执行—总结」三阶段。GovDoc 的一个任务被切成这三段,
每段跑一次 agent,段与段之间靠工作区里的文件交换状态:计划阶段产出一份计划文件,执行阶段
读它并产出一批发现,总结阶段读那批发现产出最终结果。
**工作区**——一次 agent 运行独占的沙箱目录。里面有这次运行能看的数据、能写的输出、以及
运行结束后要留下的产物。它由 GovDoc 侧建立、快照和清理,PolyLoop 不认识它。
**必须产出的文件**——每个阶段声明几个文件路径,跑完之后这些文件必须存在,否则这个阶段算
失败。这是 GovDoc 判断「阶段目标达成没有」的方式,按 `../explanation/scope.md` 它在界外。
**提交型完成**——agent 靠调用一个特定的工具来宣布自己做完了,环境状态不发生任何变化。它跟
另一种完成方式(环境自己报告「这道题结束了」)是两回事,两者都要能表达。
## 为什么这里没有迁移清单
GovDoc 有两套 agent,形态完全不同,而两套都不能直接当迁移对象。
**GovDoc-Editor 是今天跑在生产上的系统。** 它的 agent 循环归 Claude Agent SDK,业务层明令
禁止 import 那个 SDK,中间隔着一层叫 Scrivai 的编排框架。Scrivai 做的是「计划—执行—总结」
三阶段编排:每个阶段跑一次 agent,阶段之间通过工作区里的文件交换状态,每个阶段有自己的
步数上限、可用工具集和必须产出的文件清单。
`../explanation/scope.md` 的判据,**阶段编排在 PolyLoop 界外**。所以 GovDoc-Editor 不是
迁移对象,它是需求来源——它告诉我们「阶段编排要建在执行内核之上」时,对执行内核提了哪些
要求。
**GovDoc-SaaS 是 GovDoc-Editor 的重构版**,它的 `packages/docagent-core/` 正在把上面那套东西
换成自建:`workflow/phase.py` 的注释写着「三阶段泛化为 N 阶段」,`PhasedWorkflow` 自己接
`AgentLoop`Claude Agent SDK 不见了。
但那份 `AgentLoop` 不能当迁移基准。文件头第一句写着它「完整保留」某个项目的循环逻辑,而
那个项目已经废弃;更要紧的是,`grep "from docagent_core" src/` 在业务层零匹配——它有测试,
但从来没被任何业务代码调用过。**拿一份没有调用方、且血统来自废弃项目的代码当验收标准,
验的是那个废弃项目的行为。**
所以需求来源是两处:GovDoc-Editor 的生产实践,以及 `docagent-core` 作为一次已有的抽象尝试
所暴露的形状。
## 需求条目
**一、一份 agent 定义要能按次运行配不同的预算。**
生产上那份审核 agent 的三个阶段分别是 50、50、16 步。如果预算只能挂在定义上,就得为三个
阶段建三份定义,而它们除了预算之外完全一样。
**二、要能按次运行收窄工具集。**
三个阶段可见的工具不同:计划阶段有读、写、搜索、执行;执行阶段多一个技能调用;总结阶段
只有读、写、通配查找。这条和「模型可见的 schema、存在性校验、分发三者同源」不冲突——
每次运行构造一个窄的注册表就行。
**三、工具调用协议是 JSON,而且要容错。**
模型输出的是一段 JSON,含反思、计划、动作三部分,动作里有工具名和参数。实际遇到的两种
偏差都得处理:整段 JSON 被包在代码围栏里,以及参数被平铺在动作层而没有嵌套。后者的收拢
要保守——只有当除工具名之外确实存在平铺参数时才收拢,否则会把「缺参数」这个错误静默升级
成「参数为空但合法」。
**四、无效的工具调用不计有效步,但要计入总迭代上限。**
不计有效步是因为它没真的做事;计入总迭代是因为模型可能一直调用不存在的工具,没有这个上界
循环不会停。这两个计数必须分开,混成一个就防不住无限循环。
**五、提交型完成:某个特定工具被调用即视为完成。**
生产上那份 agent 靠调用一个提交工具来结束,环境状态不发生变化。这和另一种完成方式——环境
自己报告「这道题结束了」——是两回事,两者都要能表达。
**六、审计事件必须覆盖模型步与工具调用,且事件发送失败不能中断循环。**
业务侧有一条硬纪律:agent 的原始输出、修复后的输出、恢复来源全程留痕,禁止静默修复。所以
「模型原文」和「解析/修复之后的结果」要分别可见。
**七、取消要能穿透。** 长任务的所有权以数据库租约为准,取消信号从外部进来之后必须能打断
正在进行的模型调用与工具执行,并释放在途资源。
**八、工作区是一个有边界的端口。**
生产实现把它抽象成六个操作:按行读、原子写、grep、列文件、判断存在、算校验和;所有路径都
是工作区内的相对路径,越界要抛异常。按 `../explanation/scope.md`,工作区的建立、快照与清理
在界外,但**提示词的构造要能读工作区**(阶段的提示词是从上一阶段的产物生成的),这一点见
下面的缺口。
**九、阶段级续跑要能判断上一次运行是不是真的跑完了。**
阶段级续跑本身在界外,但它依赖一个界内的事实:运行结果必须是可持久化、可读回、可判定的
结构,而且「这次运行结束了」要由库自己写下来。这条已经落进 `../design/0002-step-level-resume.md`
## 已经有答案的(原缺口登记,`0003` 与 `0013` 回答)
这几条曾经登记为「PolyLoop 答不上来」,现在定了。留在这里是因为它们是 GovDoc 侧真实的接入
约束,迁移时要照着改代码。
**审计纪律由意图日志承担,不由事件流承担**`../design/0013-event-set-and-callbacks.md`
决策一与决策二)。这条原来登记成「事件流能不能覆盖审计需求,取决于事件里带不带原始响应」,
答案是不带——事件流可丢,一件只存在于可丢通道里的事实撑不起「禁止静默修复」。纪律要留痕的
三样在日志里各有位置:模型原文在模型调用结果记录的回复里,修复后的文本是步记录的
`raw_output`,恢复来源是意图日志本身(哪一步有意图没结果、按哪条重放策略处置过)。
对 GovDoc 的实质影响是**审计出口要包在存储接缝上,不是接在事件出口上**。存储的写是运行的
一部分,写失败运行就停;事件的投递不是。今天那个发一条带类型和载荷的接口迁过来之后,位置
变了。事件流仍然可以接——一步走完发一条,带整条步记录——但它是给进度回写用的,不能当审计。
**它的三类事件里有一类在界外。** `phase_recovery` 发自阶段执行器,而阶段编排不归本库;那
一层要发什么事件由它自己定。本库这边它需要的只是「这次运行结束了没有、结果是什么」,那是
存储的结束记录在回答。
**预算挂在请求上,不是定义上。** 一份定义可以被三个阶段共用,各传一份预算,不会长出三份
除预算外完全相同的定义。**不设「定义给默认值、请求可覆盖」**——两处取值意味着「这次到底
跑的什么设置」要对照两个地方才答得出来。
**提示词的内容由项目读好了传进来,库不接触工作区。** 上下文装配不是接缝,所以「让库接受
一个项目对象再转交」这条路不存在。每个阶段的提示词构造器仍然在 GovDoc 侧,它照常读工作区,
只是把读出来的结果作为上下文的一段交给库。库只管段的顺序和注入槽的位置。
**两层重试都不归本库,预算口径按库能观测的量算。** 一次模型调用之内的重试与换源归
PolyGateway,整次运行的重试归 GovDoc 自己的编排层;中间那一层(库在模型调用失败后自己再
调一次)举不出两个消费者,不做。**PolyGateway 内部换源重试不消耗任何预算**,因为库数的是
一次模型调用接缝的调用——口径必须是库自己能观测的量,否则换个后端口径就变了。
**提交型完成靠工具注册表上的完成标记。** 注册表知道哪个工具一旦被成功执行就代表目标达成,
库在动作结算之后查它。GovDoc 的动作执行接缝完成信号恒为「未完成」——它的环境状态不因为
提交而改变,所以环境这条通路对它永远不成立,收尾走的是完成标记这条。
这一条曾经差点出大事:`0003`/`0004` 的初稿把完成信号定成「布尔或空、空表示取不到」,并且
规定「取不到即环境故障」。照那个写法,GovDoc 的**每一次运行都会在第一步撞环境故障终止**。
Codex 对抗审查抓出来了,修法见 `../design/0004-stopping-and-step-record.md` 决策三 G 档。
这一条对 GovDoc 是实质变化:`docagent-core` 现有的那层步级重试(超时与网络异常,退避
20 秒和 40 秒)迁移后没有对应位置。它当初存在的理由是「治理层的某些异常类型如果没在装配点
显式注入,重试对它们就静默失效」——那是 PolyGateway 装配的问题,要在装配点解决,不是在
Agent 层补一层。
## 租户隔离参数怎么传
**绑定**是挂在一次运行请求上的一组字符串到字符串的映射,装的是下游项目自己的坐标。PolyLoop
不解释它的内容,原样交给模型调用接缝;自带的网关适配器再从里面挑出该往 PolyGateway 那次调用
上传的键。
GovDoc 在 PolyLoop 仓库提的 issue #6 说的是:适配器挑键的老办法把租户命名空间挡在外面,而那
是 PolyGateway 指定的租户隔离手段,挡掉之后两个租户提交相同的一段文本时,第二个会读到第一个
那次的模型输出。`../design/0017-gateway-forwarding.md` 决策一换掉了挑键的机制——**绑定里键名
`gateway.` 开头的才往下传,前缀之后那一段当作网关 `chat()` 的关键字参数名**。
### 要改的地方
租户命名空间与租户标识都写成带前缀的绑定键,每次运行按这次运行属于哪个租户填值:
```python
model_binding = {
"case": "...", # GovDoc 自己的坐标,不带前缀,不往下传
"gateway.cache_namespace": ..., # 这次运行所属租户的命名空间
"gateway.tenant_id": ..., # 同一个租户的标识
}
```
两个值的字符串怎么编码由 GovDoc 自己定(它的设计记录里已经定死),PolyLoop 不解释这两个字符串
的内容,也不替它们拼任何前后缀。这两个网关参数各自是什么语义、以及租户坐标为什么走绑定而不是
走适配器的构造参数,见 `0017` 的术语节与决策三。
**issue 里提的第三个维度 `meta` 本库不做**,理由在 `0017` 决策五。它原本要带的那个自己的 trace
标识改走 `gateway.session_id`:网关那边这个槽位本来就是一个用来分组的字符串,而 PolyLoop 不往
里面填任何东西,它一直留给下游自己写。
**绑定里现有的不带前缀的 `session_id` 与 `parent_call_id` 要改成带前缀的写法。** 这两个键在旧
机制下会被转发,`0017` 之后不再转发,而且会被适配器显式拒绝——用一次报错换掉一次静默的行为
变更,理由在 `0017` 决策四的第四条防御。拒绝发生在第一次模型调用时,形态是一次「第 0 步就以
模型调用失败收尾」的运行,失败说明里带着出问题的那个键名,没有预算被花掉。落地在哪个版本、
以及网关依赖下界要不要跟着动,见 `0017`
### 迁完算不算数
**绑定里再没有不带前缀的 `session_id` 或 `parent_call_id`。** 这条装上新版就自己验了:有残留
的话,用到那份绑定的第一次运行会在第一次模型调用上失败。
**每一次运行的绑定里都带着这次运行所属租户的命名空间键,值不是空串。** 空串会被适配器拒绝
`0017` 决策四的第二条防御)。查法是拿一次真实运行的运行开始记录看参数快照:带前缀的键照旧
逐字段进快照,所以「这次运行用的是哪个命名空间」事后查得到。
**同一段文本由两个租户各提交一次,各自拿到自己的那份模型输出。** issue #6 的失败场景不再复现
——这是最终判据,前面两条都是为它服务的。
**续跑一次运行时命名空间不变。** 命名空间随绑定进参数快照,而续跑开工前会把重算的快照与日志
里那份逐字段比对,换过命名空间就会被当成参数漂移中止。撞上这条说明续跑读到的缓存可能属于另一
个租户,中止是对的。
## 缺口登记
这些是 PolyLoop 现在答不上来的问题。答不上来不等于设计错了,但每一条都得有明确结论——
哪怕结论是「不支持,理由是什么」。
**模型原生的工具调用没有路。** PolyLoop 的模型调用入参上只有消息序列,没有 tools 字段,
所以工具要让模型看见,唯一的路是变成项目自己拼进上下文的文字
`../design/0010-context-assembly.md` 决策一与文末)。生产实现今天的动作协议是写在文本里的
JSON,不用原生工具调用,所以现在不缺。哪天要换成原生工具调用,PolyLoop 的模型调用入参要加一
个 tools 字段——那是兼容变更,但还要看下面那一层的共用库暴不暴露这个参数,以及原生工具调用的
返回怎么进决策解释接缝那三个分支。**这条要在换协议之前定,不能边换边定。**
**动作被拒绝那一档,工具单独的耗时拿不到。** 生产实现的 `tool_call` 审计事件记的是工具执行
那一段的毫秒数,而 PolyLoop 只记整步墙钟——里面混着模型调用、解释和动作执行三段。迁过去这个
数的口径会变,迁移前后两批数据不可比。要拆开就得往步记录里加分段计时,那是一次持久化结构的
改动,得先说清楚这个数拿去做什么(是给人看慢在哪,还是要进统计)。这条在
`../design/0013-event-set-and-callbacks.md` 的代价一节登记过,那份文档没解决它。
**动作被拒绝之后的重复行为。** 生产实现对无效工具调用不设单独的重试上限,靠总迭代上界收敛。
PolyLoop 目前的想法一致,但要确认这在长阶段(50 步)上够不够——模型反复调用同一个不存在的
工具,会把整个步数预算烧光而不产出任何东西,而这在轨迹上表现成「预算耗尽」,与真的做不完
混在一起。
## CHSAnalyzer 为什么没有对应的文档
CHSAnalyzer 是第三个已知消费者,定位是远期兼容。它的架构文档第十四节明确写了评估层和诊断层
现在是空的,而且「不为它们预留任何结构」,理由是「留了位置整个系统会立刻复杂一个量级,
而现在还不知道它们真正需要什么形状——猜出来的接缝比没有接缝更难拆」。
**没有需求可以登记,所以不建那份文档。** 等它的方案定下来再建。这一段写在这里,是为了让
读者知道这是一个决定而不是遗漏。