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.
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_TIMEOUT 于 src/ 与 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_size → EMBED__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 证实 CircuitOpenError 与 StreamLivenessTimeout 在 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:39 与 test_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_BACKEND、PGW_TELEMETRY_BACKEND、PGW_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 改键。