[English](../en/architecture.md) | 简体中文
# 架构与设计决策
这篇讲 AgentLab 的总体架构,以及几个关键设计决策的理由。给想改代码或想理解系统为什么这样设计的人看。目录结构和开发流程见[开发](development.md)。
## 总体架构
```mermaid
flowchart TB
subgraph FE["前端 · React 19 + React Flow + Zustand"]
F1["画布编辑"]
F2["问数据"]
F3["运行追踪 · 证据面板"]
F4["decode.ts
事件 → 人类可读的唯一翻译层"]
end
subgraph BE["后端 · FastAPI + LangGraph"]
B1["engine
编译 · 调度 · 中断恢复 · 出具与证据"]
B2["providers
多模型接入与成本估算"]
B3["tools
注册表 · MCP · 数据源"]
B4["memory
记忆 · 知识库 · 混合检索"]
B5["sandbox
microVM / Seatbelt / bubblewrap"]
end
subgraph ST["存储"]
S1[("agentlab.db 或 PostgreSQL
业务数据")]
S2[("checkpoints.db 或同一个 PostgreSQL
执行断点")]
S3[("artifacts/
内容寻址工件库")]
end
FE <-->|REST + WebSocket| BE
B1 --> S1 & S2 & S3
```
## 受限动态编排
大多数实际任务的步骤是已知的:写周报、核对数据、审批后发布。让模型每次现场重新规划,既是在重复发明已知答案,也让结果不可复现——不可复现就没法验收。
所以 AgentLab 的引擎是**编译式**的:运行时模型改不了图结构,它的自由被结构性地限制在 agent 节点内部和分支决策里。在此基础上分三层:
```mermaid
flowchart LR
T["模板层 · 骨架固定
已发布的不可变版本
WorkflowVersion + 图哈希
governed 级须过治理 lint"]
J["关节层 · 有边界的自由
agent 工具白名单
branch 语义分类
缺数据时降档"]
E["探索层 · 不进正式归档
画布试跑 / 临时图 / 追问
跑通的路径提取为草稿"]
T ==>|约束| J
E -.人工审核后发布.-> T
```
- **模板层**:正式运行只认已发布的不可变版本;受管级发布要过治理检查和证据门禁。
- **关节层**:授权范围内的自由;工具白名单衰减报表把「已授权但从未使用」的工具显出来,为收缩权限提供依据。
- **探索层**:结果标成探索性,不进正式归档;跑通的路径经「提取模板」沉淀为草稿,人工审核后发布。
怎么发布、怎么从探索走到模板,见[工作流](workflows.md#发布与版本)。
## 选用 LangGraph
可视化编排的难点不在画节点和连线,而在暂停、恢复与回放。LangGraph 的 checkpointer 和 `interrupt()` 把这几件事做在了底层:
- 人工介入是一次中断加一次 `Command(resume=...)`,进程重启后仍可从断点继续。
- `aget_state_history()` 直接给出每一步的状态快照。
由此还有一项收益:**agent 内部的工具审批不重复计费**。`interrupt()` 会让整个节点重放,朴素实现会重复执行之前的模型调用。agent 节点里的模型调用和工具执行都包在 LangGraph 的 `@task` 里,结果进断点,重放时直接读缓存。
## 事件流是唯一的可视化数据源
节点通过 `get_stream_writer()` 发出事件,runner 把它们转成 `RunEvent`,落库并广播。画布高亮、token 流、工具卡片、时间线全部由同一份事件驱动:刷新页面或中途接入,都能拿到完整过程(WebSocket 先补历史,再接实时)。
前端对应地只有一个翻译层 `run/decode.ts`,画布右栏、问数据页、运行详情页共用它。分设几套的后果是:同一次运行在三个页面讲出三个版本,使用者没法判断哪个是真的。
## 画布动效只表示真实发生的事
画布上每个会动的东西都对应一件真实事件(含义见[工作流](workflows.md#动效表示什么))。跑一张十几个节点的图,一眼要能看出「谁在动」;纯装饰的动效做不到,只会让画布变吵。
- 协作矩阵和右栏泳道读的是**同一份** `reduceTeam`(`run/decode.ts`),两处不会给出不同的并行度。
- 系统关掉动效时,光效整体不绘制,而不是停住:停在最后一帧的光弧和堆在起点的光点,看着像渲染坏了。
## 用护栏代替固定步数
固定步数想挡的是四件事:循环不收敛、花费越滚越大、上下文会满、真正失控。每件事都有更贴切的挡法(`engine/guards.py`):重复调用不再执行、令牌与金额预算、接近窗口先压缩再收尾,步数只留兜底。具体的值见[工作流](workflows.md#护栏)。
上限在运行发起时快照,恢复和接着跑沿用同一份:审批恢复时节点整个重放,上限一变,循环停的位置就和断点对不上。
## 沙箱分两档
轻量档用操作系统自带的访问控制(Seatbelt、bubblewrap),冷启动快,但 macOS 上限不住内存;重量档 microVM 有独立内核,内存限额真实生效,代价是第一次拉镜像要等。所以 auto 模式在镜像没缓存时先用轻量档。两档的保证、局限和实测数据见[身份、权限与安全](identity-and-security.md#代码沙箱)。
## 为什么检索是混合的
默认的 embedding 是本地特征哈希:不联网、不下载模型、零配置可用,但语义泛化能力弱。BM25 补上精确的关键词匹配。
实测(24 篇文档、16 个问题的评测集,hit@1):
| 配置 | hit@1 | 字面类 | 语义类 |
|---|---|---|---|
| 纯关键词(alpha=0) | 88% | 100% | 75% |
| 纯向量(本地哈希,alpha=1) | 81% | 88% | 75% |
| 混合 + Qwen3-Embedding-4B | 94% | 100% | 88% |
| 上述配置叠加模型重排 | 100% | — | — |
- 纯向量(81%)低于纯关键词(88%),混合才到 94%。专有名词靠 BM25,同义改写靠向量,两者各担一半——在这份语料上,混合检索是必要的,不是装饰。
- 样本只有 16 题,一题就是 6 个百分点。结论的方向可信,精度不要过度解读。
向量模型的维度靠实际探测,不按模型名推断。本地部署的模型维度各异(bge-m3 是 1024,Qwen3-Embedding-4B 是 2560);推断错了,后果不是偏差,而是全部存量向量被判定为和当前 embedder 不匹配,检索整体退回关键词。
## 为什么表格必须成为表
Excel / CSV 如果放进知识库,只能被切块检索,数字就成了模型从片段里「读」出来的。而本项目的基础约定是「所有算术下沉到 SQL 或口径卡」:产出复核能拦住凭空出现的数字,拦不住「从一堆片段里读错了一格」。所以表格上传后落成 SQLite 文件、注册成数据源(见[数据源](data-sources.md#上传表格))。
## 连线走线
自动排版之后,层与层之间按「穿过这条走廊的连线,在竖直方向最多重叠几条」预留走廊宽度。前端的 `canvas/routing.ts` 据此给每条线分配独立的竖直车道,并错开端口、让跨层的长边从没有节点的空档绕过去。`check-canvas-layout` 用几何断言守这部分(见[开发](development.md#前端检查))。