第 12 章:测试、演进风险与设计启示 —— 读完 Jan 后真正带走什么¶
12.1 测试分布透露了系统形状¶
仓库中的测试不是集中在一个端到端套件,而是分布在:
core/src/**/*.test.ts:类型、事件、extension、ModelManager、filesystem。web-app/src/**/__tests__:组件、AI elements、服务、i18n。extensions/*/src/*.test.ts:每个能力自己的行为。src-tauri/src/core/*/tests.rs:Rust 文件、线程、server、MCP。- plugin tests:router、GGUF、vector db、backend。
这适合跨语言 monorepo:最接近实现的位置验证局部不变量,减少一次完整 Tauri 启动的成本。代价是跨层契约容易出现“每层单测都通过、组合失败”。
12.2 最重要的局部不变量¶
| 不变量 | 保护位置 |
|---|---|
| 同一 thread 的 message 文件不被并发写坏 | per-thread async lock |
| create/modify race 不产生重复消息 | id dedupe / upsert |
| 同一 MCP server 不会重复 initialize | mcp_starting guard |
| MCP 断线重试不无限加快 | capped exponential backoff |
| 下载暂停可恢复、取消会清理 | CancellationToken + paused set |
| provider 不接受的 sampler 不上 wire | capability filtering |
| factory reset 清理 localStorage 在 store hydrate 前完成 | boot sentinel + dynamic import |
| llama.cpp busy 时退出不直接杀进程 | graceful stop loop |
| 删除 thread 不遗留向量 collection | UI cleanupVectorDB |
这些不变量比类名更值得记住;它们是系统可靠性的骨架。
12.3 演进风险一:协议重复¶
ThreadMessage 同时存在于 Core TS、AI SDK UIMessage、Rust serde JSON、local JSONL、SQLite blob 和外部 OpenAI-like message。任何字段变化都可能需要迁移和转换。
建议的演进策略是:
- 明确“存储格式”和“模型 wire format”不是同一个类型。
- 在 boundary adapter 中做版本化,不要在每个 UI 组件里散落兼容逻辑。
- 为旧 JSONL、旧 assistant、旧 model settings 保留 fixture。
12.4 演进风险二:状态分裂¶
同一件事可能有多个真相源:model loaded 状态在 plugin、extension、app state 和 UI local status 中各有一份;MCP server 状态也在 config、active map、running service、monitor task 中分散。
这不是简单的“应该只有一个 store”。长生命周期资源确实只能由 Rust 持有,UI 需要自己的派生状态;真正需要的是:
- 明确 owner:谁能修改状态。
- 明确 snapshot:UI 看到的是哪个时刻的事实。
- 明确 event:谁负责把变化传播给其他层。
- 明确 reconcile:刷新/重连后如何从后端重建 UI。
12.5 演进风险三:前端 transport 过重¶
custom-chat-transport.ts 同时处理上下文、工具、schema、音视频、采样参数和容错。它很强,但也容易成为“第二个后端”。如果未来继续扩展能力,应把纯函数逻辑(参数清洗、schema normalize、message normalize、context estimate)拆出,保留 transport 只做 orchestration。
12.6 设计启示:把能力分成四种失败等级¶
L0 核心聊天:失败时必须给出可恢复 UI
L1 本地模型/远程模型:可替换 Provider,至少一个可用
L2 工具/MCP/Web Search:失败时聊天仍能继续
L3 RAG/分析/遥测:失败时不应破坏主流程
Jan 的很多实现已经遵循这个层级:MCP 初始化失败不阻塞其他 server,RAG plugin 不可用时普通聊天仍存在,ModelFactory 支持多 Provider,错误会保留在 message metadata 中供恢复。
12.7 如果要自己实现一个 Jan-like 系统¶
最值得复用的不是具体组件,而是以下模式:
- 协议先行:先定义 Thread/Message/Model/Extension,再让前端和宿主实现它们。
- 平台服务隔离:通过 ServiceHub 把浏览器、桌面和移动实现统一起来。
- 能力可替换:模型和工具用 provider/extension 注册,不让 UI 直接依赖一个后端。
- 短态与长态分离:流式 chunk 留在 session,稳定 message 才落盘。
- 资源有 owner:进程、密钥、文件、连接必须由一个明确的宿主状态拥有。
- 失败是产品状态:busy、paused、reconnecting、context overflow 都应进入 UI 状态机。
12.8 结语¶
Jan 的复杂性不是因为它“堆了很多功能”,而是因为它把桌面系统的现实全部暴露出来:文件会坏、网络会断、模型会 OOM、Provider 参数不兼容、子进程不会按时退出、平台能力不对称、UI 需要即时反馈。
如果把 Jan 读成一个聊天 UI,会觉得代码分散;如果把它读成一组边界管理器,就会看到清晰的主线:Core 定义协议,Web 编排体验,Rust 管理资源,plugins 承载平台能力,存储负责恢复。