全貌
先把形状看一眼,再往下读细节。图上的两个带(入口、能力)夹着编排——它是一张 LangGraph 图,所以画成一个节点;加外挂的模型层与状态层,就是下面「五层」那张 表的形状。数据源在 Trove 之外,所以不在这五层里;行动的执行同理—— 判定触发产出的提案、审批与回执在系统内,执行在外(webhook 推 / MCP 拉)。 实线是「谁调谁」,虚线是「谁会用到大模型」;两条都不是数据流。
flowchart TB
%% 配色 = 站点 token 的半透明版(zinc 中性 + indigo 主色 + 链路各段一色):
%% 填充一律低透明度、描边用实色、文字不动色——亮暗两档同一份源码都成立
%% (mermaid 把颜色烘进 SVG,跨主题只有透明度能自适应)。
subgraph entry["入口"]
UI["Web UI"]:::entry
SRV["HTTP 服务"]:::entry
CLI["CLI / REPL"]:::entry
MCP["MCP"]:::entry
end
WF["编排 · trove/workflow<br/>LangGraph 工作流<br/>语义门禁 → 计划与编译<br/>生成 → 执行与校验 → 反思"]:::core
subgraph caps["能力 · trove/services"]
SEM["语义模型"]:::cap
ANA["分析 · 归因分解 · 统计器械"]:::ana
KB["知识库 + 混合检索"]:::cap
MEM["记忆 · 判定规则 · Skills"]:::cap
ACT["行动 · 提案 / 审批 / 回执 / 效果验收"]:::act
end
LLM["模型 · trove/llm<br/>LLM 网关"]:::base
DS["数据源<br/>PostgreSQL · MySQL · Doris<br/>ClickHouse · DuckDB · SQLite<br/>Snowflake · BigQuery"]:::ext
STATE["状态 · trove/storage<br/>PostgreSQL / SQLite<br/>会话 · 任务 · 检查点<br/>查询日志 · 谱系"]:::base
UI --> WF
SRV --> WF
CLI --> WF
MCP --> WF
WF --> SEM
WF --> ANA
WF --> KB
WF --> MEM
MEM -->|"判定触发"| ACT
WF -.-> LLM
KB --> DS
MEM --> STATE
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef core fill:#6366f11f,stroke:#6366f1,stroke-width:2px;
classDef cap fill:#6366f114,stroke:#6366f199,stroke-width:1.5px;
classDef ana fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
classDef act fill:#8b5cf614,stroke:#8b5cf699,stroke-width:1.5px;
classDef base fill:#71717a14,stroke:#71717a59,stroke-width:1.5px;
classDef ext fill:transparent,stroke:#71717a80,stroke-width:1.5px,stroke-dasharray:4 4;
style entry fill:#71717a0d,stroke:#71717a2e,stroke-width:1px
style caps fill:#71717a0d,stroke:#71717a2e,stroke-width:1px
linkStyle 8 stroke:#f59e0b,stroke-width:1.5px
五层
| 层 | 目录 | 职责 |
|---|---|---|
| 接口层 | trove/api/ | HTTP 路由、鉴权依赖、SSE 流式输出 |
| 工作流层 | trove/workflow/ | LangGraph 图、节点、确定性规则链、版本比对 |
| 服务层 | trove/services/ | 知识库、语义模型、检索、记忆、判定、行动、分析、Skills、数据源、任务调度、认证与授权 |
| 模型层 | trove/llm/ | litellm 网关、agent 循环骨架、追踪 |
| 存储层 | trove/storage/ | 内部状态的统一后端抽象与版本化迁移 |
值得说明的是服务层与工作流层的关系:工作流层不含业务知识, 它只负责「按什么顺序、带什么上下文去调用服务」。所以同一个 KbService, 对话链路、评测脚本、MCP 通道都在用,而它们的编排方式各不相同。
多个入口,一张图
进程入口有三个:REPL / CLI(trove、trove-cli)、
HTTP 服务(trove serve,Web UI 走的是它)与
MCP(trove mcp)。它们最终都汇到同一张 LangGraph 图上——
图不是在每个入口各建一份,而是由统一的工厂装配。
main.py:119 create_app_components graphs.py:1402 build_graphs graphs.py:2109 _build_reflection main.py:382 build_graphs
build_graphs 的开关默认值并不一致:agentic、multi_candidate、query_sketch 默认开,只有 clarify 默认关。生产调用只传了 checkpointer,其余全取默认值——所以对外报的节点数必须是默认口径下的那张图。
把 clarify 算进去会得到 29。它确实存在,但只在显式开启时挂上;反过来,把某个默认开的开关当成关,报出来的数也会偏低。读者照着自己跑一遍,看到的应该是 28。
服务的装配与注入
节点是工厂函数:make_xxx(services...) -> async def node(state)。
依赖在构图时被绑进闭包,运行期节点不需要去任何全局注册表里找东西。
这样做的好处很直接:测试可以给某个节点单独注入假的 LLM 或假的连接器,
而不必启动整个系统。
服务集合本身是一个 GraphServices 对象,由 main.py 在启动时装配。
图拿到的是已经构造好的实例,而不是构造参数——装配逻辑与流程逻辑因此可以分开演进。
一次提问的数据流
从高一层看,一次问答经过这几个阶段(细节在查询工作流):
| 阶段 | 产出 | 是否用 LLM |
|---|---|---|
| 意图路由 | 判定这是问数 / 元数据 / 闲聊 / 纠错 / 写操作… | 是 |
| 语义链接 | 这个问题涉及哪些表(matched_tables) |
检索为主,确定性过滤为辅 |
| 快径尝试 | 命中模板则直接出 SQL | 否 |
| 计划与编译 | 查询计划 → 权威 SQL 或骨架 | 计划用,编译不用 |
| 生成 | SQL(agentic 循环,带工具自校验) | 是 |
| 执行与校验 | 结果集 + 规则链判定 | 否 |
| 反思与回滚 | 判定这一轮是否进步,决定重试还是回退 | 是(判定有确定性版本比对兜底) |
| 解释与交付 | 人话解释、图表、结论 | 是 |
这张表里「是否用 LLM」那一列是这套架构的核心信息:越靠近正确性判断的环节, 越不该用 LLM。结果对不对由规则链判、这一轮有没有变好由版本比对判—— 只有理解和表达交给模型。
内部状态:一个抽象,两档后端
会话、任务、用户事实、记忆、认证、设置、任务调度、查询日志——
这些东西此前散在多个 SQLite 文件里,现在统一到一个 StorageBackend 抽象后面。
另有两处是另一回事——不是被排除,而是机制本来就不同:
图检查点用 LangGraph 自带的 saver(认同一个
TROVE_STORAGE_URL 开关,但不经过这套 store 代码),
血缘库是服务自管的一份 SQLite 镜像。
两处的后端选择跟着同一个开关走,代码路径却各是各的。
sqlite.py:138 build_backend sqlite.py:166 resolve_backend checkpoint_store.py:38 build_checkpointer service.py:122 lineage.sqlite
| 场景 | 后端 | 怎么选中 |
|---|---|---|
| 生产 | PostgreSQL | 设了 TROVE_STORAGE_URL |
| 本地 / 测试 | SQLite | 未设该变量时的兜底 |
两个后端共用同一套 store 代码,靠的是后端自己做 SQL 方言翻译:
? 占位符转 %s、AUTOINCREMENT 转
IDENTITY、需要 lastrowid 时改写成
INSERT ... RETURNING id。这让上层 store 不必写两份 SQL。
KB 镜像与业务数据源适配器留在外面。前者依赖 FTS5 虚拟表(PostgreSQL 生产走 pg_hybrid 检索后端,是另一条路);后者是用户的数据,本来就不该被 Trove 的内部存储抽象管。
也就是说:这个抽象管的是「Trove 自己的状态」,不是「所有数据库访问」。
迁移是版本化的,并且有两条不变量:每个操作各自幂等(这才让「从 0 重放」 与「存量库接入」走同一条代码路径),以及版本号反映历史而非配置。 第二条的意思见混合检索里那个退役列的例子。
数据源:一个注册表,驱动按需加载
数据源适配器由一张注册表管理。驱动是懒导入的—— 导入注册表本身不需要装任何 extras,只有真正去连某个类型的库时才要求对应的驱动包。
registry.py:42 _ADAPTER_REGISTRY
所以「Trove 支持哪些数据源」的答案不是写死在文档里的一句话,而是注册表的 key 集合
(检查器会数它)。新增一个方言的工作量集中在实现 DatabaseAdapter 的几个方法:
连接、执行、取 schema、上报能力、声明方言。
接口层
HTTP 服务由工厂函数构造,OpenAPI 文档挂在 /v1/docs——
也就是说接口清单有一份活文档,不必手抄进站点。
这也是API 参考只给分类清单与场景的原因。
app.py:325 create_app app.py:334 FastAPI app.py:473 health
鉴权是 fail-closed 的:没有认证组件时服务拒绝启动,而不是「裸奔运行并打条警告」。 这条规则在安全边界里展开。