# 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 是"整段缺失":

```python
# 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` 改键。
