Files
PolyGateway/research-wiki/migrations/govdoc-saas.md
T
iomgaa 4e06d5e801 docs: widen the GovDoc usage_source note to three states
The migration doc is a standing constraint on library design, so an
outdated enum there states an outdated fact. B13's substance survives
the change -- GovDoc's zero was never the problem, the missing label
was -- but the row now names unavailable and the cache_hit-qualified
gap query alongside it.
2026-07-30 11:18:44 -04:00

20 KiB

GovDoc-SaaS 迁移文档(迁移即验收)

Note

迁移已完成并验收(2026-07-22,M4;verifier 清零)——worktree 分支 feat/polygateway-migration,证据见 findings/2026-07-22-m4-acceptance.md §1。实施对本文的勘误: ① 文中 polygateway[redis,telemetry-sqlite] extras 不存在,实际为 [redis,structured](SQLite 遥测零 extra;docagent-core 本体依赖裸 polygateway,因 types.py re-export);② §1"不迁 embedding(待 Q3)"已被 Q3 拍板(2026-07-20 纳入 M2)推翻——实施已将 embedding 换 EmbeddingClient + PgwEmbeddingProvider 薄适配器(保留文件路径,删 165 行手写实现);③ 装配点净新增为 api/assembly.py + app.py lifespan(arq worker 入口骨架期尚不存在,届时按 assembly.py 同款接入)。

定位: 本文是 ARCHITECTURE.md §11.1 的展开——库建成后如何合并进 GovDoc-SaaS、替换其哪些内部组件。它既是 M4 迁移的操作指南,也是 M1-M3 设计的反向约束(库公共 API 必须让本文描述的迁移成立)。与 ARCHITECTURE.md 冲突时以 ARCHITECTURE.md 为准。 证据基线: 2026-07-20 对 reference/GovDoc-SaaS/ 的代码实测(Read/Grep/wc),所有 file:line 相对该仓库根。


1. 迁移目标与验收定义

验收公式: 删除清单(§2)全部删除 → import 替换(§3/§4)完成 → GovDoc 原测试全绿(make ci,含 docagent-core 余量测试 + govdoc 业务测试 + import-linter 契约)。凡替换不掉的能力即库的边界缺口,回补后重验。

两个阶段:

阶段 时机 范围 不做什么
最小接入冒烟 M1 后 不删任何 GovDoc 文件;新增一个装配 spike(GatewayClient.from_env() 注入 AgentLoop),真实打通一次 chat 调用,验证 LLMProvider 结构兼容与 LLMResponse 字段消费 不动 llm/ 子包、不改 protocols/types、不迁 .env
全量迁移 M4 §2 删除清单 + §3 替换映射 + §5 配置迁移 + §6 步骤全量执行,验收公式达成 不迁 embedding(待 Q3,见 §2)、不做任务外重构

特别说明: GovDoc 的装配层(配置→client)从未存在——.env 定义了全部 LLM 参数但零 Python 消费者(grep LLM_MODEL|LLM_BASE_URL|LLM_API_KEY|LLM_TIMEOUTsrc/packages/ 无命中),src/govdoc/ 现仅 api 骨架(鉴权桩 api/deps.py 16 行,任意 token 归 t_mock 租户)且零处 import docagent_core。因此"最小接入"对 GovDoc 而言不是替换,而是库补上从未写完的那一段;GovernedLLMClient 迄今没有任何生产调用方,迁移风险天然低。

2. 现状盘点

治理相关文件全量清单(行数 wc -l 实测):

文件(相对 GovDoc 根) 行数 职责一句话 迁移后命运
packages/docagent-core/src/docagent_core/llm/client.py 582 GovernedLLMClient: 熔断→缓存→重试+SSE 流式→遥测四层治理 + provider 字符串猜测 删除(库 client+middleware+transport 继任)
.../llm/breaker.py 70 进程内熔断器(now 注入、force_open) 删除(库 backends/memory 继任)
.../llm/streaming.py 131 三层活性看门狗纯函数 删除(库 streaming.py 近原样继任)
.../llm/redis_cache.py 94 sha256 内容寻址响应缓存,静默降级 删除(库 backends/redis 缓存继任)
.../llm/telemetry_sqlite.py 204 SQLite 遥测(WAL/幂等/to_thread) 删除(库 telemetry/sqlite.py 继任)
.../llm/__init__.py 0 删除(随子包)
.../docagent_core/protocols.py 151 10 个共享 Protocol,其中 LLM 相关 2 个 改写: 删 TelemetryRecorder(唯一消费者是被删的 client.py/telemetry_sqlite.py);LLMProvider 保留(agent/workflow 的消费契约,GatewayClient 结构化满足);其余 8 个非 LLM 端口不动
.../docagent_core/types.py 25 LLMResponse frozen dataclass(11 字段) 改写: 改为 re-export 库的 LLMResponse(一行 shim,超集兼容),下游 import 路径零改动
.../retrieval/embedding.py 165 Embedding 客户端,含独立手写重试(embedding.py:107-131,第三处重复) 删除,换 polygateway.EmbeddingClient(Q3 已拍板,M2 已交付)。映射: batch_sizeEMBED__BATCH_SIZE;dimension 校验 → EMBED__EXPECTED_DIM(不符抛 ResultInvalidError,原 EmbeddingUnavailableError 语义由四分类承接);自研退避 → 库退避(封顶+jitter,原版无上限无 jitter 为有意升级);on_usage 回调 → 遥测 llm_calls 行(prompt_tokens)+ pricing cost,消费方改读遥测库
packages/docagent-core/tests/test_breaker.py 32 熔断状态机单测 删除(等价测试随库实现交付)
packages/docagent-core/tests/test_imports.py 10 公共 API import 冒烟(第 5-6 行 import 被删模块) 改写 import 目标
packages/docagent-core/tests/conftest.py ScriptedLLM fake(构造 LLMResponse) 保留(经 types.py re-export 零改动)
.env.example 51 LLM/Redis/韧性参数定义(现无人读) 改写(键名映射见 §5)
packages/docagent-core/pyproject.toml 23 核心依赖 httpx/loguru/pluggy/json-repair/pydantic;extras: redis/postgres 改写: 移除 redis extra(grep 实证 redis 在 docagent_core 内唯一消费者是被删的 redis_cache.py);httpx/json-repair 保留(embedding.py 与 agent/loop.py 仍用)
pyproject.toml 85 govdoc 业务包依赖与 import-linter 契约 改写: dependencies 增 polygateway[redis,telemetry-sqlite]

agent/workflow/taskrun/retrieval/(除 embedding 的重试外)与 src/govdoc/ 全部保留——它们是编排层/业务层,不在库边界内(ARCHITECTURE §2.1)。

3. 组件替换映射表

GovDoc 组件 PolyGateway 对应物 签名兼容性(实测对比) shim
GovernedLLMClient(client.py:206) GatewayClient GovDoc 端口 LLMProvider.chat(messages, *, session_id, parent_call_id) -> LLMResponse(protocols.py:19-25);ARCHITECTURE §5.2 只定义 ChatRequest 未明列 chat() 签名,§11.1 承诺兼容 → 见 §9-G2,M1 设计必须定稿此签名 若库签名定稿一致: 无 shim,GatewayClient 结构化满足 LLMProvider(runtime_checkable);否则业务侧 5 行包装类
LLMResponse(types.py:9-25,11 字段) LLMResponse(§5.1 超集: 同名 11 字段 + source_name/cost/usage_source) 字段名逐一比对一致(content/thinking/model/provider/prompt_tokens/completion_tokens/latency_ms/ttft_ms/max_inter_token_ms/cache_hit/call_id);§5.1"只增不删不改名"硬约束覆盖 types.py 改一行 re-export
CircuitBreaker(breaker.py:10) BreakerMW + InMemoryBreakerState 内部组件,无外部签名消费(grep 证实 CircuitOpenErrorStreamLivenessTimeout 在 llm/ 之外零捕获点)
RedisResponseCache(redis_cache.py:15) CacheMW + Redis 缓存后端 同上,内部组件;key 公式变化见 §7-B1
SQLiteTelemetryRecorder(telemetry_sqlite.py:20) telemetry/sqlite.py GovDoc 遥测端口参数名 model_name(protocols.py:38)vs 库 §7.8 model;因端口连同实现一起删除、业务侧零直接调用,不构成兼容问题
stream_with_liveness_timeouts(streaming.py:72) streaming.py 同源纯函数(注释自证"参考 CHSAnalyzer2"),库方向近原样移植
CircuitOpenError(client.py:36) CircuitOpenError 异常类型全变(docagent_core.llm.client.* → polygateway.errors.*);业务侧无捕获点,风险为零
装配层 GatewayClient.from_env() 净新增——GovDoc 没有可对比的旧物

4. 调用点清单

grep 实测:全仓 .py.chat( 仅 1 处(测试 fake 除外);GovernedLLMClient 无任何构造调用点。

调用点(file:line) 现状 迁移后形态
packages/docagent-core/src/docagent_core/agent/loop.py:377 await self._llm.chat(messages, session_id=..., parent_call_id=...)——唯一直接调用点 零改动(前提: §9-G2 签名定稿)
.../workflow/executor.py:117 AgentLoop(self._llm, max_steps=...) 透传 LLMProvider 零改动
.../agent/loop.py:98 AgentLoop 步级重试默认 retryable_exceptions=(TimeoutError, OSError),注释明言用于兜底穿透治理层的瞬时异常 非零改动: 库异常(TransientError/AllSourcesExhausted/CircuitOpenError)非 OSError 子类,不改则步级重试静默失效。修法: 装配处构造 AgentLoop(..., retryable_exceptions=(TransientError, AllSourcesExhausted))——改在装配调用点,loop.py 本体不动
`packages/docagent-core/tests/test_imports.py:5-6 import 被删的 CircuitBreaker/GovernedLLMClient 改写为 import polygateway 对应物
tests/conftest.py:39test_agent_loop.py 各构造点 fake 构造 docagent_core.types.LLMResponse 零改动(types.py re-export;库新增字段须有默认值,见 §9-R3)
装配调用点 不存在(见 §1) 净新增: govdoc api lifespan / arq worker 入口各一处 from_env()

5. 配置迁移

.env 键名映射(旧键实测自 .env.example:24-41;库键按 ARCHITECTURE §9,PGW_* 命名待 M1 设计定稿):

旧键 库键 语义差异
LLM_MODEL / LLM_BASE_URL / LLM_API_KEY LLM__{PROVIDER}__1__MODEL / __BASE_URL / __API_KEY 平铺单源 → 多源命名(单源=长度 1 特例);改名成本为零,因旧键无任何代码消费
(无) LLM__{PROVIDER}__1__ENABLE_THINKING 旧实现 thinking 是构造参数(client.py:235)但 .env 从未定义——净新增键
LLM_TIMEOUT / LLM_MAX_RETRIES / LLM_RETRY_BASE_DELAY / LLM_RETRY_MAX_DELAY / LLM_TTFT_TIMEOUT / LLM_INTER_TOKEN_TIMEOUT 同名沿用(§9 承诺)
LLM_CIRCUIT_BREAKER_THRESHOLD / _COOLDOWN 同名沿用 旧 .env 注释"实际阈值 = max(此值, concurrency*2)"是手动约定(.env.example:38),库自动计算(§7.4)
REDIS_URL 库缓存后端连接配置 旧值同时服务 arq/SSE(.env.example:13 注释),迁移后业务自用部分保留原键
REDIS_CACHE_TTL 库缓存 TTL 键 旧实现允许 None=永不过期(redis_cache.py:89-92);库强制 TTL>0(§7.5)
(无) namespace(租户)、PGW_LIMITER_BACKENDPGW_TELEMETRY_BACKENDPGW_QUOTA_FULL 净新增: 缓存命名空间必填;限流从"完全没有"变为存在(§7-B7)

装配对比——before 是"整段缺失":

# BEFORE(现状): 不存在。.env 定义了 LLM_* 但无人读;
# GovernedLLMClient(15 个构造参数)从未被任何入口构造。

# AFTER(govdoc api lifespan / arq worker 入口,净新增约 5 行):
from polygateway import GatewayClient, TransientError, AllSourcesExhausted

client = GatewayClient.from_env()          # 读 .env 装配全治理栈
loop = AgentLoop(client, max_steps=...,
                 retryable_exceptions=(TransientError, AllSourcesExhausted))
# 关闭: lifespan 退出时 await client.aclose()(见 §9-R5)

6. 迁移步骤

# 步骤 验证方式 回滚点
1 feature 分支;记录基线 make ci 全绿输出 ci 日志留存 分支起点 commit
2 根 pyproject 加 polygateway[redis,telemetry-sqlite],make install python -c "import polygateway" commit
3 冒烟阶段(M1 后即可做): 新增装配 spike,from_env() + AgentLoop 真实调用一次,校验 isinstance(client, LLMProvider) 与响应字段 spike 输出 + 遥测库有记录 commit(spike 可独立丢弃)
4 .env/.env.example 按 §5 映射改键、补 namespace 等新键 from_env() 启动无缺配置报错 commit
5 装配进 api lifespan 与 arq worker 入口(含 retryable_exceptions 显式传参与 aclose) govdoc 业务测试绿 commit
6 删除 llm/ 六文件 + test_breaker.py;改写 types.py(re-export)、protocols.py(删 TelemetryRecorder)、test_imports.py;docagent-core pyproject 移除 redis extra make ci 全绿 + import-linter 通过(含"docagent-core 子包互不依赖"契约,根 pyproject:71-88) commit
7 验收: 验收公式逐项核对 + 全新上下文 verifier 独立验证 ci 输出 + verifier 报告

任一步失败即回退上一 commit;第 6 步前的所有步骤不删旧代码,新旧可共存。

7. 旧版行为审计

# 现有行为(file:line) 处置
B1 缓存 key = llm_cache: + sha256({model, messages}),无租户/命名空间/salt(redis_cache.py:42-48)——违反 GovDoc 自身"缓存 key 含租户维度"铁律(其 CLAUDE.md §4.2) 修复: 库 key 必含 namespace(§7.5);须支持 per-call 租户,见 §9-G1
B2 缓存 TTL 可为 None=永不过期(redis_cache.py:89-92) 修复: 库强制 TTL>0
B3 缓存读取时 json.loads(raw)LLMResponse(**data) 在 try 块之外(redis_cache.py:70-71)——损坏的缓存条目会抛异常击穿整次调用,违背"静默降级"自述 修复: 库反序列化失败按 miss 处理
B4 熔断半开无单探针互斥: 冷却到期 is_open所有并发调用返回 False(breaker.py:36-37),惊群探测 修复: 库半开只放一个探针(§7.4)
B5 错误二分类: transient={429,500,502,503,504,ConnectError,ReadTimeout,WriteTimeout,SSE 异常,看门狗}(client.py:169-186);fatal={401,403} force_open(client.py:170,401);其余(含 400)记遥测后直接抛(client.py:463-481) 替换: 四分类。429+insufficient_quota→SourceDead、400→RequestRejected 显式化是升级
B6 httpx.ConnectTimeout/PoolTimeout 不在瞬时判定内(client.py:182 仅列 ConnectError/ReadTimeout/WriteTimeout;按 httpx 异常层级 ConnectTimeout 继承 TimeoutException 而非 ConnectError)——连接超时不重试的缺陷 修复: 库将全部 httpx Transport 错误归 Transient(§6.2)
B7 限流完全没有(连 semaphore 都无;全仓无并发闸) 修复/净新增: M1 内存限流。注意 arq 多 worker 下内存限流按进程各自计数,全局限额需 M2 Redis 后端——迁移文档明示此过渡期语义
B8 单源、无换源、无 Retry-After 解析(重试仅指数退避+jitter,client.py:451-460) 替换: 多源+换源+Retry-After 取大者;GovDoc 配单源即长度 1 特例
B9 遥测调用 5 处逐字复制(client.py:315缓存命中/376成功/403致命/425瞬时/464非重试) 替换: 库单一 helper 铁律(§7.8)
B10 缓存命中也记遥测(cache_hit=True, latency_ms=0)(client.py:309-331);每次 attempt 独立 call_id(client.py:337);thinking 帧 content 优先于 reasoning_content(client.py:79-91) 保留(库同款语义)
B11 SSE 流提前断开且未见 [DONE] 时正常返回:usage_sink["done"] 写入后无人检查(client.py:115-117),截断响应被当成功并写入缓存 修复: 库把"断流无 [DONE]"定性 TransientError(§6.1),且坏结果不进缓存
B12 provider 差异靠字符串猜: "deepseek" in provider/"qwen" in provider 注入 thinking 参数(client.py:139-144)、<think> 剥离(client.py:348) 替换: provider 注册表(D11)
B13 usage 帧缺失时 prompt/completion_tokens 落 0(client.py:354-355),无标注 升级: 库 usage_source 三态 measured/estimated/unavailable(2026-07-30 est_tokens 解耦后由两态扩为三态)。GovDoc 原行为"落 0 且无标注"中的落 0 反而与库一致,被升级的是标注:缺失行记 unavailable 且 cost 为 NULL,缺口可被 WHERE usage_source='unavailable' AND cache_hit = false 量化
B14 遥测 schema 无 source_name/cost/usage_source(telemetry_sqlite.py:29-48) 升级: 库超集 schema;GovDoc 骨架期无生产遥测数据,直接换新库文件,不做数据迁移
B15 双层重试: 治理层 max_retries + AgentLoop 步级 step_retries(loop.py:308-356,默认延迟 (20,40)s) 有意保留(业务侧任务级重试,ARCHITECTURE §7.2 允许留在库外),但须按 §4 改 retryable_exceptions,否则静默失效
B16 CancelledError 穿透重试循环(client.py:396 except Exception 天然放行),取消的调用不记遥测 保留穿透;取消是否记遥测库未定义,见 §9-R6
B17 熔断按 provider 字符串 key(client.py:295)而非源 替换: 库按 source_name 分别计数

8. 行为差异与风险

差异/风险 影响 缓解
缓存 key 公式全变(B1/B2) 迁移后既有缓存全 miss,一次性成本 GovDoc 未上生产,成本≈0;无需灰度
库新增限流(B7) 此前无限流的调用可能开始排队/fail-fast 初期配宽限额;PGW_QUOTA_FULL=wait;M2 前明知内存限流是每进程口径
步级重试静默失效风险(B15) 忘改 retryable_exceptions 则韧性兜底悄然消失 迁移步骤 5 显式传参;冒烟阶段注入 TransientError 验证兜底触发
异常类型全换(§3) 未来业务代码若按旧类型捕获会漏 现零捕获点(grep 实证);删除旧类即编译期暴露
LLMResponse 新增字段 dataclasses.asdict 序列化面变宽(审计/遥测消费) §5.1 只增不删;conftest 构造点依赖新字段有默认值(§9-R3)
截断流从"假成功"变为重试(B11) 行为更正确但延迟分布变化(多一轮重试) 属预期升级,遥测可观测
遥测表结构变化(B14) llm_calls 表不兼容 新库文件起步;生产 Postgres 遥测属 M2(§9-R4)

9. 对库的反向约束清单

⚠️ 架构缺口(ARCHITECTURE.md 现设计与项目实际的不匹配,M1 设计文档必须解决):

# 缺口 证据 里程碑
G1 缓存租户维度缺 per-call 通道: §7.5 将 namespace 描述为装配级("项目名/租户 id"),但 GovDoc 是单 GatewayClient 服务多租户,tenant 每请求变化;§5.2 ChatRequest 的"per-call 覆盖项"未明确含缓存 namespace/tenant。若只有装配级 namespace,GovDoc 的多租户缓存隔离铁律无法满足,B1 修复不成立 GovDoc CLAUDE.md §4.2 多租户铁律;api/deps.py 每请求解析租户 M1
G2 GatewayClient.chat() 公共签名未定稿: §11.1 承诺兼容 chat(messages, *, session_id, parent_call_id) "或一行 shim",但 §5 未把 session_id/parent_call_id(及 G1 的 per-call 租户)列入 chat 签名/ChatRequest 字段。不定稿则 §4 的"loop.py:377 零改动"无法承诺 protocols.py:19-25;loop.py:377 M1

其余反向约束(现设计已覆盖或属细化,逐条对应里程碑):

# 约束 里程碑
R1 from_env() 工厂必须存在且覆盖单源装配(GovDoc 装配层缺失,库是唯一装配来源);缺关键配置报错而非默认值兜底 M1
R2 错误类型(TransientError/AllSourcesExhausted/CircuitOpenError)必须在库顶层公开导出,供业务侧 AgentLoop 步级重试引用(§4 非零改动点的前提) M1
R3 LLMResponse 新增字段(source_name/cost/usage_source)须带默认值,保证 GovDoc 测试 fake 的旧 11 字段构造(conftest.py:39)零改动 M1
R4 Postgres 遥测后端(GovDoc 生产要求,telemetry_sqlite.py:1 docstring 自证"生产环境必须由业务侧注入 Postgres 实现") M2
R5 GatewayClient 须提供显式关闭 API(如 aclose())供 FastAPI lifespan/arq shutdown 释放 httpx 连接——§7.1 每源一个 AsyncClient 但未定义客户端生命周期公共 API(旧实现有 close(),client.py:580) M1
R6 定稿"被取消的调用是否记遥测"(旧行为不记,B16;库"遥测必录"铁律未涵盖取消路径) M1
R7 内存限流在多进程(arq)下的口径须在文档/配置中显式警示(B7 过渡期语义),避免误配全局限额 M1

以上 G1/G2 若按本文方向解决,GovDoc 全量迁移中业务代码(agent/workflow/govdoc api)对治理层的消费零改动,改动收敛于: 装配净新增、types.py/protocols.py/test_imports.py 三处改写、.env 改键。