Files
NotesAgentic/docs/retrospectives/后端全面审阅问题与修复复盘.md

14 KiB
Raw Permalink Blame History

后端全面审阅问题与修复复盘

审阅日期:2026-08-28 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。

2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储和 Agent Trace 持久化,当前完整后端回归基线为 81 项测试通过。

1. 审阅结论

审阅前的主链路已经能够运行,原有 50 项测试全部通过,但测试没有覆盖首次失败、畸形扩展包、代码型 Markdown、浏览器字符偏移和真实 Provider Streaming 等边界。

本轮共处理 10 类问题:

编号 问题 原级别 处理结果
R-01 首次索引重建失败留下半成品 P1 已修复并补充失败注入测试
R-02 代码围栏中的 # 被误判为标题 P1 已增加 fenced code block 状态解析
R-03 Citation 偏移与浏览器 UTF-16 不一致 P1 已统一为 UTF-16 code unit
R-04 未知权限默认放行,Plugin 缺少授权记录 P1 已改为白名单和默认拒绝,并增加授权接口
R-05 第一阶段 Tool 与接口不完整 P1 已接入 Note Move、Task、Attachment 和转写适配链路
R-06 Provider SSE 不是真实增量流 P1 已接入 OpenAI SSE 与 Ollama JSONL
R-07 畸形 Plugin Schema 导致 500 P2 已在安装和调用阶段执行 JSON Schema 校验
R-08 Provider PATCH 无法清空可空字段 P2 已按 model_fields_set 实现正确 PATCH 语义
R-09 Vault 扫描可能跟随链接读取外部文件 P2 已增加真实路径范围校验
R-10 Agent Run 与 Trace 无界保留 P2 已增加 Run、事件和单轮 Tool Call 上限

修复后后端共有 62 项自动化测试通过。

2. R-01:首次索引重建失败留下半成品

原因

旧实现只在正式数据库已经存在时创建 .bak。首次启动时没有旧库,重建过程会直接创建正式数据库并逐篇写入;如果第二篇或后续笔记解析、Embedding 或向量写入失败,异常分支没有可恢复的备份,也没有删除新建数据库。

后果

  • 接口报告重建失败,但搜索仍能看到部分笔记;
  • Metadata、FTS 和向量索引可能只覆盖 Vault 的一部分;
  • 用户无法区分旧索引、半成品索引和完整索引;
  • 再次重建前,Agent 可能基于不完整知识回答。

审阅时通过失败注入实际复现:初始数据库不存在,第二篇笔记抛错后,数据库残留 1 篇 Note 和 2 个 Block。

解决思路

失败后的状态必须与重建前一致:有旧库时恢复旧库,没有旧库时删除本次创建的数据库。同时限制并发重建,避免多个任务覆盖同一备份。

解决方案

  • 使用共享 Vault Mutation Lock 串行化重建与 Note 创建、更新、移动、删除,避免文件和索引写操作交错;
  • 备份文件名带 job_id,不再共用固定 .db.bak
  • 首次重建失败时删除本次创建的数据库;
  • 记录 running、failed、last completed 和 error 状态;
  • Index Job 最多保留 100 条;
  • 新增“首次重建第二篇失败”的回归测试。

3. R-02:代码围栏中的 # 被误判为标题

原因

旧解析器逐行使用标题正则,没有维护 Markdown fenced code block 状态。Python、Shell、YAML 等代码中的注释行符合 Markdown 标题正则。

后果

  • 编程笔记产生不存在的标题层级;
  • 后续正文被挂到错误的 heading_path
  • Block 切分、RAG 上下文和 Citation 章节定位错误;
  • 相同正文在重新编辑后可能生成不同 Block ID。

解决思路

标题和空行分块只应在普通 Markdown 上下文执行。进入反引号或波浪线围栏后,整段代码应作为普通正文收集,直到合法闭合围栏出现。

解决方案

  • 识别 ````` 和 ~~~ 围栏;
  • 围栏内部不执行标题识别和空行分块;
  • 代码块作为独立 Block 保留原始内容;
  • 增加 Python # code comment 回归测试。

4. R-03Citation 偏移单位不一致

原因

Python len() 返回 Unicode code point 数量,而 JavaScript 编辑器通常按 UTF-16 code unit 定位。emoji 和部分扩展汉字占一个 Python 字符,但占两个 UTF-16 单元。

后果

只要 Citation 前出现非 BMP 字符,前端按 start_offset / end_offset 跳转时就会错位,字符越多偏移越大。

解决思路

偏移是跨语言 Contract,必须明确单位。项目主要消费者是 Vue 和浏览器编辑器,因此后端直接输出 UTF-16 code unit,避免每个前端调用点重复转换。

解决方案

  • _split_lines() 按 UTF-16 长度累加;
  • Block 结束偏移和 frontmatter 正文起点使用相同单位;
  • 接口文档明确 Citation 偏移为 UTF-16 code unit
  • 增加 emoji 位于正文之前的回归测试。

5. R-04Permission 与 Plugin 授权 fail-open

原因

PermissionPolicy 对未知权限返回 allow。Plugin Runtime 只验证 Tool 使用的权限是否写进 Manifest,没有判断该权限是否属于项目命名空间,也没有区分“声明权限”和“用户已经授权”。

后果

  • notes.wirte 一类拼写错误会静默放行;
  • 新增高风险权限但忘记更新 Policy 时默认无确认执行;
  • Plugin Enable 无法向用户展示和记录权限决策;
  • Manifest 声明被错误地当成用户授权。

解决思路

权限边界应 fail-closed。声明、授权和单次运行确认是三个不同状态,不能共用一个布尔判断。

解决方案

  • 建立 KNOWN_PERMISSIONS 白名单;
  • 未知权限默认 deny
  • Skill 和 Plugin 安装时拒绝未知权限;
  • Plugin Contract 增加 granted_permissions
  • 新增 PUT /api/plugins/{plugin_id}/permissions
  • 带权限 Plugin 在授权完成前保持 permission_required
  • 撤销必要权限时自动停用 Plugin
  • 增加未知权限、授权前启用和授权后启用测试。

6. R-05:第一阶段 Tool 与业务接口缺失

原因

早期先建立了 API 壳子,Note Move、Task 和 Media 路由保留为 501Agent Tool Registry 也只接入了 Note 基础读写与搜索。这与技术栈文档列出的第一阶段 Tool 集合不一致。

后果

  • 前端能够看到接口,但一调用就得到 501;
  • Agent 无法完成移动笔记和任务管理;
  • Attachment 与音频链路没有统一 Tool Contract
  • “第一阶段完成”的说法无法按技术基线验收。

解决思路

补齐能够在当前架构安全落地的能力。真实语音模型仍属于第二阶段,因此第一阶段提供 Host transcript 适配,不伪装成已经集成 faster-whisper。

解决方案

  • 实现 notes.move,移动后保持原 note_id,失败时恢复文件;
  • 新增 SQLite v2 migration 和 Task CRUD Service
  • 接入 tasks.createtasks.updatetasks.list
  • 新增 Host 管理的 attachments 目录和 attachments.read
  • 接入 audio.transcribe,读取 Host 预生成 transcript
  • Transcript Job 最多保留 100 条;
  • 实现原 Task、Media 和 Move HTTP 路由;
  • 增加 Note Move、Task 生命周期和 Attachment/Transcript Tool 测试。

当前边界

audio.transcribe 当前只负责统一调用链和读取 Host 生成的文本。faster-whisper、pyannote.audio 与真实音频推理仍按照技术栈说明在第二阶段实现。

7. R-06Provider Streaming 只是 SSE 外壳

原因

TurnStreamingMixin 先调用非流式 complete(),等待完整回答后只发送一个 TextDelta。OpenAI-Compatible 和 Ollama 请求都明确设置 stream=false

后果

  • 首字等待时间等于完整回答生成时间;
  • 长回答无法边生成边展示;
  • SSE 连接存在,但不具备真实 Streaming 的用户体验;
  • 上游生成期间无法及时反馈 Tool Call 或 Usage。

解决思路

Adapter 应直接消费各 Provider 的原生流协议,再映射成统一 ModelEvent

解决方案

  • OpenAI-Compatible 使用 httpx.AsyncClient.stream() 消费 SSE
  • 解析 TextDeltaThinkingDelta、Tool Call 分片、Usage 和 Done
  • Ollama 使用相同连接消费 JSONL
  • 统一递增 Event sequence
  • Provider HTTP 错误继续映射为统一错误码;
  • 增加两个 Adapter 的增量分片测试。

8. R-07:畸形 Plugin JSON Schema 返回 500

原因

旧实现假定 parameters.properties 一定是对象,启用阶段直接调用 .items()。扩展包可以提交 properties: [],安装成功后在启用时触发未捕获 AttributeError

后果

  • 客户端输入错误被当成服务端故障;
  • 统一错误 Contract 被破坏;
  • 已安装记录进入 error,但用户拿不到可处理的 Schema 错误;
  • JSON Schema 中的 enum、长度和嵌套约束没有在 Tool 调用时落实。

解决思路

Schema 是不可信扩展输入,应在安装阶段检查结构,并在每次 Tool 调用时校验实际参数。

解决方案

  • 引入 jsonschema
  • 安装时执行 Draft 2020-12 Schema Check
  • 要求 Tool 参数根节点和 properties 为对象;
  • Tool Registry 在 Pydantic 校验前执行 JSON Schema 校验;
  • 所有格式错误转换为 PLUGIN_TOOL_SCHEMA_INVALID
  • 增加畸形 properties 回归测试。

9. R-08Provider PATCH 无法清空字段

原因

旧路由使用 model_dump(exclude_none=True)。该调用无法区分“字段没有发送”和“客户端明确发送 null”。

后果

用户无法清空 credential_idbase_urldefault_model,只能删除 Provider 后重建;同时直接 model_copy(update=...) 不会重新验证完整模型。

解决思路

PATCH 必须根据 Pydantic 的 model_fields_set 判断客户端实际发送了哪些字段,并在合并后重新验证 ProviderConfig。

解决方案

  • 使用 model_fields_set 构建更新字典;
  • 允许可空字段显式设置为 null
  • 禁止 nameenabled 显式设置为 null
  • 合并后通过 ProviderConfig.model_validate() 重新验证;
  • 增加同时清空三个可空字段的测试。

10. R-09Vault 扫描越过根目录

原因

旧重建逻辑直接读取 rglob("*.md") 的结果,只使用词法相对路径,没有验证符号链接解析后的目标是否仍位于 Vault。

后果

在支持符号链接的平台上,Vault 内链接可以指向外部 Markdown。外部内容随后进入 FTS、向量索引和 RAG 上下文,并可能发送给外部模型 Provider。

解决思路

扫描和 Note API 应使用同一条路径安全原则:先 resolve(),再验证真实目标仍位于解析后的 Vault 根目录。

解决方案

  • 扫描开始时解析 Vault 根目录;
  • 每个 Markdown 路径执行 resolve()
  • 不在 Vault 内的真实路径直接跳过;
  • 文件状态和正文均从验证后的真实路径读取。

11. R-10Agent Run 与 Trace 无界增长

原因

旧 Runtime 将所有 Run、Agent Event、Tool Result 和 Citation 永久保存在进程字典中,没有 TTL、数量限制或持久化后的裁剪。

后果

桌面应用运行时间越长,内存占用越高。notes.read 等 Tool Result 还可能携带整篇 Markdown,使单个 Run 的体积明显增加。

解决思路

在 SQLite Trace Repository 接入前,先给内存实现设置明确上限,并在容量不足时返回可处理错误,不能让进程无限增长。

解决方案

  • 最多保留 200 个 Run
  • 创建新 Run 时优先淘汰最旧的终态 Run;
  • 全部是活动 Run 且达到上限时返回 429 AGENT_CAPACITY_EXCEEDED
  • 每个 Run 最多保留 2000 个 Event
  • 单轮最多接受 50 个 Tool Call
  • Index Job 和 Transcript Job 同样设置 100 条保留上限。

12. 文档同步

本轮同时修正以下文档漂移:

  • 后端接口契约不再把 Notes、Search、Skills、Plugins、Tasks 和 Index 标为未实现;
  • 补充 Plugin 权限设置接口;
  • 补充真实 Provider Streaming 说明;
  • 明确 Citation 偏移单位;
  • 更新 Tool 列表和 Attachment / Transcript 边界;
  • 删除 Knowledge 文档中的 Note Move 未实现说明;
  • 测试数量从旧的 26 更新为当前完整数量。

13. 验证方法

执行:

cd backend
uv lock --check
uv run python -m compileall -q app
uv run pytest -q -p no:cacheprovider
git diff --check

验证结果:

71 passed
compileall passed
uv lock --check passed
git diff --check passed

测试覆盖新增了:

  • 首次重建失败回滚;
  • fenced code block 标题隔离;
  • UTF-16 Citation 偏移;
  • Note Move ID 稳定;
  • Plugin 权限授权和未知权限拒绝;
  • 畸形 JSON Schema 拒绝;
  • Task CRUD
  • Attachment 与 transcript Tool
  • Provider PATCH 显式 null
  • OpenAI SSE 与 Ollama JSONL 增量事件。
  • Provider 预设、模型发现、凭据缺失/鉴权错误映射;
  • 凭据密文落盘、API 不回显明文及 Provider 解密读取。

14. 后续工作

本轮解决的是第一阶段后端正确性和契约问题。以下内容仍按技术基线留在后续阶段:

  • Run、Trace、Provider 和 Extension Registry 的完整 SQLite 持久化;
  • faster-whisper、pyannote.audio 和真实音频任务队列;
  • MCP Plugin Host 与独立进程健康检查;
  • OpenAI Responses 与 Anthropic Messages Adapter
  • 真实 Embedding / Reranker 模型;
  • 增量索引、文件监听和 RAG / Agent Benchmark。