583c012706
Add research-wiki/migrations/ (govdoc-saas, video-tree-trm5, chsanalyzer): deletion lists, component mappings, call-site inventories, config migration, stepwise rollback plans, legacy behavior audits, and reverse constraints on the library design including flagged architecture gaps.
170 lines
18 KiB
Markdown
170 lines
18 KiB
Markdown
# GovDoc-SaaS 迁移文档(迁移即验收)
|
|
|
|
> **定位**: 本文是 `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,第三处重复) | **保留(暂)**: 待 ARCHITECTURE Q3 决策(建议 M2 纳库),本次迁移不动 |
|
|
| `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` |
|
|
| 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` 改键。
|