Files
group_fqcd_jr/docs/evidence/20260909-memory-chain-acceptance.md
lzf_0626 6516ccb385 feat: 第二版——接口契约对齐 docs/05,修复静默故障与数据库基线
相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。

一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
   approve→reviews(需 body decision)、activate→activations、
   rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
   UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
   RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
   输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
   {data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
   不再返回 FastAPI 默认的 {"detail": ...}。

二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。

三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
  顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
  但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
  召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
  .env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。

四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。

五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。

验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
2026-09-10 15:55:54 +08:00

81 lines
6.6 KiB
Markdown
Raw Permalink 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.
# 记忆链路端到端验收(2026-09-09)
对照 `docs/evidence/20260909-memory-baseline-before.md` 定义的判据,验证记忆链路是否真正连通。
本文件在 P1 验收后更新,已纳入 P2 的抽取环节。
## 一、验收方式
`tools/memory_chain_probe.py`——真实 MySQL、真实 `WorkerRuntime` 租约与治理链、真实
`OutboxWorker` 消费、生产公开接口。为不依赖尚未配置的模型端点,探针通过
`WorkerRuntime(model_service=..., endpoint_resolver=...)` 注入两个确定性替身,
使「抽取」这一段同样被端到端覆盖,而不是靠单元测试代替。
```powershell
python tools/memory_chain_probe.py
```
## 二、验收结果:全部通过(含抽取)
| 环节 | 断言 | 结果 |
|---|---|---|
| 生产装配 | 模型服务、工具执行器、意图分类器均已注入工厂 | PASS |
| 受理 | `AgentRunApplicationService.accept` 返回 run_id | PASS |
| 执行 | Worker 领取并执行完成,`agent_run.status = succeeded` | PASS |
| 事件契约 | `complete_run` 同事务写入 `memory.extraction_requested` | PASS |
| 事件载荷 | payload **不含正文**,只有 `run_id` / `message_id` / `customer_id` | PASS |
| 消费 | Worker 轮询 3 轮清空该 run 的事件队列 | PASS |
| 抽取 | 抽取模型被真实调用、端点经解析器解析 | PASS |
| 记忆写入 | `memory_unit` 由 0 变为 1 行 | PASS |
| 抽取结果 | 键为受控词表 `preference:risk_level`、内容为结构化值「稳健型」而非用户原文、类型 `preference` 与键前缀同构 | PASS |
| 归属 | `customer_id` 为发起运行的客户 | PASS |
| 幂等 | 同一 `event_id` 重复消费被拦截,记忆行数 1 → 1 | PASS |
| 清理 | 探针数据清理后残留 0 行 | PASS |
## 三、断点与修复对照
| 修复前 | 修复后 | 证据 |
|---|---|---|
| `WorkerRuntime` 只注册 `agent.run_requested`,记忆事件永不消费 | 注册 4 个 handler(含 `memory.extraction_requested`) | 探针第 5 步 PASS |
| 消费者读 `payload["content"]`,而事件按设计不含正文 | 按 `message_id`/`run_id` 回查 `conversation_message` | 探针内容断言 PASS |
| 无幂等边界,重复消费重复写 | `event_id` 作为 `memory_evidence.idempotency_key` | 探针第 6 步 PASS |
| 记忆内容是用户原文整句、键为 `conversation.{session_id}` | 模型严格 JSON 抽取 + 受控语义键 | 探针抽取结果断言 PASS |
| `MemoryConflict` left/right 自引用、ORM 列名与库不符 | 已修复并与库列名对齐 | `tools/audit_constraints.py` PASSED |
| `memory_unit` 缺 `active_memory_key` 生成列与唯一键 | 迁移 `20260909_memory_active_key` | 空库重建与既有库审计均 PASSED |
| P2 引入的回归:同一 Session 先查询触发 autobegin,`complete_run` 抛 "A transaction is already begun" | 只读查询改用独立 Session,并补失败堆栈日志 | 探针由 FAILED 转为 PASSED |
| 信号识别重叠误命中与漏命中 | 重写模式表 + 11 个回归测试 | `tests/unit/service/test_memory_taxonomy_signals.py` |
| P3 三个服务零调用点(召回 / episode / 级联都是死代码) | 分别接入治理链、Worker 轮询与事件 handler | 探针 PASSED;episode `persisted_rows=1`;级联 `memory_status=invalidated` + 审计 1 行 |
| `EpisodeWorker` 只 `flush` 不提交,接线方漏 commit | 接线层显式 `session.begin()` 并写明契约 | 由 `inserted=1 但 rows=0` 暴露,修正后 `rows=1` |
| 召回缓存命中后降级状态丢失(写入带 `degraded`、读取硬编码 `False`) | 缓存读写都携带 `degraded_reasons` | `test_cached_degraded_result_keeps_degraded_flag`(原为 strict-xfail,已转正) |
| 向量后端直接抛异常会中断整条召回(与"只降级不阻塞"承诺不符) | `_vector` 增加异常兜底 | `test_vector_backend_exception_is_degraded_not_raised`(原为 strict-xfail,已转正) |
| `recall_with_decay` 中 `Decimal * float` 抛 `TypeError` | 排序键显式转 `float` | 接线后首个真实数据集成测试触发并修复 |
## 四、必须记录的环境事实
1. **零配置环境无法端到端运行 Agent**。`model_endpoint_config` 为 0 行,`IntentClassifier` 在端点
为空时按设计失败关闭(`RecoverableAgentError`),run 无法进入 `complete_run`。记忆抽取同样
失败关闭(无端点时不落任何记忆)。业务接入前必须至少配置一个模型端点与一个受控备用端点;
本探针用替身绕开这一环境依赖,只验证链路本身。
2. **Outbox 消费吞吐**。`dispatch_one` 每轮只领取一条事件,`jr` 库现有 262 条 pending
`agent.run_requested`,Worker 常驻后需约 262 轮(默认轮询 1 秒)清空。不是缺陷,但积压深度
需要纳入运维观测。
3. **记忆键已语义化**(P1 遗留问题已由 P2 解决)。键来自受控词表,同一会话的不同事实落到不同键,
不再互相覆盖;同一客户同一键的并发写入由 `active_memory_key` 唯一键保证只有一条有效记录。
4. **向量召回通道已启用,只差环境配置**。`MemoryRecallService` 已接入主链路,组装层
`build_memory_recall_service` 现同时传入 `vector=VectorMemoryAdapter(...)`(Milvus 可达时)
与 `embed=_embed_text`。`_embed_text` 走发布配置解析 `task_type=embedding` 端点;当前
`model_endpoint_config` 为 0 行,因此带查询文本的召回会按设计降级为
`embedding_failed` 并**保留结构化结果**(实测:`items`/`degraded_reasons` 符合预期,
向量化失败不抛异常、不中断召回)。配好 embedding 端点后语义召回无需改代码即可生效。
注意:无查询文本时 `_vector` 直接跳过,不产生降级标记——这也是实测中 `degraded=False`
的原因,属设计行为而非漏检。
5. **结构化召回是字面匹配,语义匹配依赖向量通道**。实测查询「风险偏好」在只有结构化通道时
返回 0 条:`recall_with_decay` 的关键词过滤按空白切分,中文查询整体作为一个 term,
只有字面出现在记忆内容或键里才命中。语义等价的查询(记忆内容是「稳健型」)需要向量召回,
这正是通道 4 的价值所在——不应通过放宽关键词过滤去"修"它。
## 五、边界
本验收证明"受理 → 执行 → 事件 → 消费 → 抽取 → 记忆 → 幂等"链路连通;不证明抽取质量本身
(由 `test_memory_extraction_service.py` 的契约测试覆盖)、不证明向量召回的语义效果
(Milvus 未接真实实例,降级路径由 P3 单元测试覆盖)、不证明 episode 聚合的业务价值。