# car_agent
**Repository Path**: bighandsomeboy/car_agent
## Basic Information
- **Project Name**: car_agent
- **Description**: No description available
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: main
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-10-14
- **Last Updated**: 2026-10-15
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# reback · 车载智能问答与任务型对话 Agent
面向智能座舱场景的统一对话 Agent:把 Tesla 车主手册的**知识问答**,和音乐、导航、
车控、空调这类**任务型对话**,收进同一套系统;本地一条命令起得来,浏览器里点得动。
以 [Qwen-Agent](https://github.com/QwenLM/Qwen-Agent) 为主模板,复用它的模型接入 /
MCP 客户端 / 多轮上下文 / 流式输出,**自己写的只有车载领域那部分**。
- **基线文档**:[开发实施计划书 V2.0(跑通优先版)](docs/车载智能问答Agent_开发实施计划书_V2.0_跑通优先版.md)。
V2.0 自启用后替代 V1.0,冲突时以 V2.0 为准。
- **进度与阻塞项**:[PROJECT_STATUS.md](PROJECT_STATUS.md) **架构决策**:[DECISIONS.md](DECISIONS.md)。
- V2.0 的口径变化:技能目录 420 → **约 30 个**;不设 90% 准确率硬指标,只设「最低健康线」
用于发现链路是否失效;默认后端是**本地 Ollama**(vLLM 降为可选验证);
服务器部署、真实第三方 API、微调**均不影响项目完成**。
---
## 1. 这是什么
一句话进,出来四件事之一:**答**(带可定位引用)、**做**(经 MCP 调工具改车状态)、
**追问**(还差一个槽位)、**说答不了**。四件事在**同一条链路**上,不是四个系统拼的。
| | |
|---|---|
| **做** | 知识问答(有据、可溯源)/ 任务执行(真调工具)/ 缺槽追问 / 敏感操作二次确认 |
| **不做** | **车外信息服务**(天气、航班、订餐)—— 问「帮我叫个外卖」的期望结果是**拒识**,不是假装能做(D-042,用例 e2e-12) |
| **不接** | 真实车辆。`TOOL_MODE=mock`,车控/媒体/导航全是内存状态机,界面上带「Mock 模拟,未连接真实车辆」横幅(§6.1 要求标注) |
| **不部署** | 服务器。本地 MongoDB + Milvus + Ollama + Mock 工具**就是**正式演示环境(V2.0 §1.2 / §4.2) |
**最要紧的一条:模型不选工具。**
选哪个技能的是 `SkillRouter`(向量召回先给候选白名单,模型只在候选里挑,输出还要过
`validate_decision` 校验),**调用**工具的是 MCP。所以这个项目诚实的说法是
**「技能路由 + MCP」**,不是 Function Calling —— §5.1 验收原文写的是
「Function Calling / **MCP 链路**」,本项目只做了后者。理由四条见
[D-045](DECISIONS.md),其中最硬的一条是:§6.1 把「需要确认的操作在确认前被执行」
列为阻塞项,而确认闸门只有落在自己代码里才能被测试钉住。
规模(都有机器可查的出处):
| 项 | 数 | 出处 |
|---|---|---|
| 技能 / 领域 / 槽位 | **26 / 5 / 35**,`catalog_hash=6ad1b75f034c9978` | `python -m scripts.phase4_skills` |
| 工具 / MCP Server | **24 / 3**(其中 **2 个工具没有技能映射**,只用于独立复核状态) | `python -m scripts.phase5_tools --list` |
| HTTP 端点 | **7** | §4 |
| 语料 | **603 块**(56 页 PDF + 12 页网页 → 165 父块 / 438 子块) | `data/processed/phase1_report.json` |
| 索引 | **438 行**(pdf 338 + web 100,dim 1024) | `data/processed/phase2_index_report.json` |
---
## 2. 架构
### 2.1 组件图
**裁决块在 MCP 之前** —— 这个顺序就是 D-045 那张图要纠正的东西。
```mermaid
flowchart TB
subgraph CLIENT["客户端"]
WEB["apps/web/
静态页(无构建链)"]
CLI["scripts/phase3_ask.py
phase4_route · phase5_chat"]
CURL["curl / 任意 HTTP 客户端"]
end
subgraph API["apps/api —— 一个 uvicorn 进程,--workers 1"]
EP["7 个端点
/health · /ready
/api/v1/chat(+ /stream)
/retrieve · /vehicle/state
/sessions/{id}/cancel · /"]
ENV["envelope.py · streaming.py · sse.py
13 键信封 · SSE 事件"]
ENG["DialogueEngine(src/dialogue)
待确认 → 待补槽 → 裁决 → 兜底"]
end
subgraph DECIDE["裁决层 —— 模型不选工具,这里选"]
ROUTER["SkillRouter(src/routing)
BGE-M3 向量召回 Top-K 候选
模型只在候选里挑 → validate_decision"]
CAT["configs/skills/skills.yaml
26 技能 / 5 领域 / 35 槽位"]
end
subgraph INVOKE["调用层 —— MCP 只负责调用"]
MCPC["MCP 客户端"]
S1["FastMCP:navigation"]
S2["FastMCP:media"]
S3["FastMCP:vehicle"]
TOOLS["24 个工具
共用一份 ToolRuntime"]
end
subgraph RAGP["知识路径"]
PIPE["RagPipeline"]
MILVUS[("Milvus
dense + sparse_bge + BM25
→ RRF → 可选精排")]
PROMPT["严格中文 Prompt
证据包 + 拒答标记"]
end
LLM["LLM:本地 Ollama qwen3.5:9b
(OpenAI 兼容口)"]
MONGO[("MongoDB
内容与元数据")]
WEB --> EP
CLI --> ENG
CURL --> EP
EP --> ENV --> ENG
ENG -->|"task:技能 + 已校验槽位"| MCPC
ENG -->|"先问裁决"| ROUTER
ROUTER --> CAT
ROUTER -.->|"只输出技能与槽位的 JSON"| LLM
ENG -->|"没选到技能 → 默认落 RAG"| PIPE
PIPE --> MILVUS
PIPE --> PROMPT --> LLM
MCPC --> S1
MCPC --> S2
MCPC --> S3
S1 --> TOOLS
S2 --> TOOLS
S3 --> TOOLS
MILVUS --- MONGO
```
三个 MCP Server 跑在**同一个进程**里(`inmemory` 传输,共用一份 `ToolRuntime`)——
这不是"还没拆开",本地 MVP 的形态就是这样,`TRANSPORTS` 里另两种(`stdio` / `local`)
也留着。它带来的后果是真实的:会话状态与车辆状态都在进程内存里,所以**只能
`--workers 1`**(见 §7)。
### 2.2 一轮对话的生命周期
`POST /api/v1/chat` 进去之后,**三步的顺序是固定的**,不能调换:
```mermaid
sequenceDiagram
autonumber
participant C as 客户端
participant A as FastAPI
participant E as DialogueEngine
participant R as SkillRouter
participant L as LLM(Ollama)
participant M as MCP 工具
participant G as RAG / Milvus
C->>A: POST /api/v1/chat {text, session_id}
A->>E: 跑一轮
Note over E: ①②③ 顺序固定,先到先接管
E->>E: ① 有未决的确认吗(整句严格匹配)
E->>E: ② 有未补的槽吗(接住上一轮)
E->>R: ③ 都没有 → 交给裁决层
R->>L: Top-K 候选 + 技能目录片段
L-->>R: 技能 + 槽位 + 缺槽 + none_of_above
R->>R: validate_decision(不合法最多打回重问一次)
R-->>E: skill_id + 已校验槽位 / 或"没有匹配的技能"
alt 缺必填槽
E-->>C: 追问一句(status=needs_input,不渲染成「没能完成」)
else 敏感操作(目录里声明 requires_confirmation 的那 4 条)
E-->>C: 只反问,tool_calls 为空
else 直接执行
E->>M: 经 MCP 协议调用工具
M-->>E: status = success / needs_input / error
E-->>C: 按 status 说「已完成 / 还差什么 / 没能完成」
end
alt 裁决层说"没有匹配的技能"
Note over E,G: 先看是不是问候闲聊;不是就默认落 RAG,不默认拒答
E->>G: 检索 → 证据包 → 严格中文 Prompt
G->>L: 证据 + 问题
L-->>E: 答案 + 引用,或拒答标记
end
E-->>A: TurnResult(10 键)
A-->>C: 信封(13 键,10 键的超集)
```
两条容易搞混的:
- **「敏感操作」在目录里是 4 条**:`vehicle.trunk`(开后备箱)、`vehicle.window`(降车窗)、
`vehicle.honk_flash`(鸣笛闪灯)、`vehicle_status.set_charge_limit`(设充电上限)。
数它要跑 `python -m scripts.phase4_skills`(那行写的是「需二次确认的动作」)。
- **拒识默认落 RAG。** 裁决层没选到技能时,引擎先判是不是问候/闲聊(`chat`),
不是的话就交给 RAG 去查手册;RAG 答了且**用上了引用**才是 `knowledge`,否则
`reject`。所以 `reject` 不是"没匹配上技能",而是"走完了知识路径也没答出来"。
- **`route=='reject'` 是四种情形的并集**:RAG 拒答、答了但一条引用都没有、
RAG 没配、以及 **LLM 链路故障**(答话是「没能查手册:模型这边出了问题」)。
所以**不能拿 `route` 当拒识的判据** —— 模型一挂,那条断言反而变绿,
一次故障会被记成一次合格的拒答(这个坑在 e2e-02 上真踩过,见 §5)。
---
## 3. 快速开始(六步)
顺序就是计划书那条:**装依赖 → 起 Mongo/Milvus → 起 Ollama → 导数据建索引 →
起 API/Web → 跑评测**。每步一条命令,附期望输出。
### 第 1 步 · 装依赖
```bash
pip install -r requirements.txt
pip install -r requirements-dev.txt
cp .env.example .env
```
`.env.example` 里就是本机默认值,通常直接能用。**本项目只有一个前端**:`apps/web/`
的静态文件由 FastAPI 自己挂载(`StaticFiles(html=True)`),没有构建链、没有额外依赖
(阶段 0 留下的 `requirements-gui.txt` 已在阶段 7 删除,D-064)。
期望输出 —— 验收门:
```bash
python scripts/check_env.py # 末行:合计:0 FAIL / 0 BLOCKED / 0 WARN(退出码 0)
python -m src.runtime_guard # 断言 qwen-agent==0.0.34 且用的不是 vendor 快照
```
`check_env.py` 会逐项报告 Milvus / Mongo / Ollama 通不通;**这几个服务还没起时
对应的项会 FAIL**,先做完第 2、3 步再回来看。
### 第 2 步 · 起 MongoDB 与 Milvus
```bash
# 向量库(Milvus standalone + etcd + minio)
docker compose -f configs/milvus/docker-compose.yml up -d
# 文档库(MongoDB 7,只存内容与元数据;向量在 Milvus)
docker run -d --name reback-mongo -p 127.0.0.1:27017:27017 \
-v reback-mongo-data:/data/db --restart unless-stopped mongo:7
docker ps --filter name=reback --format '{{.Names}}\t{{.Status}}'
```
期望:三到四行 `Up`。
### 第 3 步 · 起 Ollama 并拉模型
```bash
ollama serve # 另一个终端;默认监听 127.0.0.1:11434
ollama pull qwen3.5:9b # 本机实测约 6.1 GB
ollama list # 确认真实标签
```
**V2.0 §3.1 定的是本地 Ollama,不是 vLLM。** vLLM 只在以后服务器可用时做可选验证。
`LLM_BASE_URL` 走 OpenAI 兼容口 `http://127.0.0.1:11434/v1`,`LLM_API_KEY` 填 `EMPTY`
(Ollama 不校验 Key,但 qwen-agent 需要一个非空值)。**标签对不上时只改 `.env`,
不改业务代码。**
### 第 4 步 · 导入数据、建立索引
```bash
python -m scripts.phase1_ingest # 解析 → 清洗 → 分块 → 写 Mongo → 出质量报告
python -m scripts.phase2_build_index # 438 块灌进 Milvus,建三路索引
```
期望输出(本机实测):
- 阶段 1:`documents=13 / pages=56 / chunks=603`(165 父块 + 438 子块)、
`images=137`、`web_sources=12`,验收自检 5/5。
- 阶段 2:`chunk_index` collection,`num_entities=438`(pdf 338 + web 100),
`dim=1024`、`count_matches=true`。
> ⚠️ **语料本身不在这个仓库里。** `data/raw/*` 被 `.gitignore` 排除(版权与体积),
> 公开仓库里只有 `.gitkeep`。要复现这一步,得自己按
> [`configs/sources/pdf_sources.yaml`](configs/sources/pdf_sources.yaml) 与
> [`configs/sources/tesla_web_manifest.yaml`](configs/sources/tesla_web_manifest.yaml)
> 把源文件放到 `data/raw/` 下的对应路径(PDF 放 `manuals/`,网页快照放 `web/`)。
> 两个清单里都写着来源 URL、语言、车型、版本与内容哈希 —— **溯源信息是登记过的**;
> PDF 那个 `source_url_verified: false` 是如实标注"这个 URL 我没有真的下载比对过"。
### 第 5 步 · 起 API 与演示页
```bash
uvicorn apps.api.main:app --host 127.0.0.1 --port 8000 --workers 1
# 浏览器打开 http://127.0.0.1:8000/ ← 演示页挂在同一个进程上
```
**`--workers 1` 不是"暂时这么写",是这套设计下唯一成立的形态。** 会话状态
(待确认、待补槽、最近几轮)和车辆状态都在**进程内存**里。起 2 个 worker,
「打开后备箱」的待确认会落在 A 进程,紧接着的「取消」可能路由到 B 进程 ——
现象是"点了取消但没反应",偶发、难查。所以不实现多 worker,也不声称支持分布式会话。
期望:**启动要 20 秒出头**(本机实测 22.3s / 22.4s,其中 BGE-M3 单独加载就占 14.87s
—— 技能路由本身要用它,所以它必然在 `/ready` 之前,见 §6)。
```bash
curl -s localhost:8000/health # 只验进程活着,恒 200,不碰依赖
curl -s localhost:8000/ready # 六项探针 → ready(200) / degraded(503),逐项说清坏在哪
```
两者分开是有用的:进程活着但 Ollama 没起,是"页面能打开、一问就报错";
进程都没了,是"页面根本打不开"。合成一个端点,这两种在监控上长得一样。
### 第 6 步 · 跑评测与测试
```bash
python -m pytest -q # 762 passed,约 61~67 秒(两次实测 61.29s / 66.91s)
# 各阶段闸门(会连库/连模型)
python -m scripts.phase4_skills # 26 技能 / 5 领域 / catalog_hash 不变
python -m scripts.phase5_tools --list # 24 工具,无技能映射的恰好 2 个
python -m evaluation.dialogue.run_dialogue_eval # 10/10,确认取消闸门 5/5
# 端到端 12 条(起真 uvicorn,打真 HTTP;硬断言 < 9 条就非零退出)
python -m evaluation.end_to_end.run_e2e_eval
```
**`pytest` 不是纯离线的。** 762 条里绝大多数是单元测试(不碰网络、不加载模型),
但集成层那 11 条(`tests/integration/test_api_live.py`)会**真加载一次 BGE-M3**
(`setup` 项 16.98s,整份套件里最慢的就是它)**并真调 Ollama** ——
所以跑 `pytest` 需要 Mongo / Milvus / Ollama 都起着,`ollama ps` 里能看到模型被抬进来。
缺 `data/raw/` 时阶段 1 那几条语料测试会 `skip`(并把原因写在明处),条数会比 762 少。
跑真模型的几份(数字与 §6、§8 对得上):
```bash
python -m evaluation.rag.run_retrieval_eval # 5 套配置 × 30 题,约 6 分钟
python -m evaluation.rag.run_rag_eval # 20 题,约 2 分钟
python -m evaluation.routing.run_routing_eval --repeat 3 # 30 + 20 题,约 5 分钟
```
评测报告统一落在 `evaluation//reports/`,命名是 `<名字>_<时间戳>.md` +
`latest_<名字>.md`。**报告 markdown 是报告 json 的纯函数** —— 不想重跑就用
`--render-only ` 重新渲染。
---
## 4. 接口一览
| 端点 | 干什么 |
|---|---|
| `GET /health` | 只验进程活着(恒 200,不碰依赖) |
| `GET /ready` | 依赖状态:`ready`(200) / `degraded`(503),六项逐条给 `detail` |
| `POST /api/v1/chat` | 一轮对话 → 13 键信封 |
| `POST /api/v1/chat/stream` | 同一条链路的 SSE(事件名见下) |
| `POST /api/v1/retrieve` | **只检索不生成**,调试用;返回 `latency_ms` + 实际生效的 `source` + `pack` |
| `GET /api/v1/vehicle/state` | 车 / 媒体 / 导航状态 + `call_counts` + `mock` + `transport` |
| `POST /api/v1/sessions/{id}/cancel` | 取消待确认/待补槽,**恒 200** |
| `GET /` | 演示页(静态文件) |
数一下:**业务端点 7 个**(`/health`、`/ready`、`chat`、`chat/stream`、`retrieve`、
`vehicle/state`、`sessions/{id}/cancel`),`GET /` 是挂静态页的,不在这 7 个里。
### 信封的 13 个键
| # | 键 | 说明 |
|---|---|---|
| 1-10 | `trace_id` `session_id` `route` `answer` `skill` `slots` `citations` `tool_calls` `vehicle_state` `latency_ms` | 契约点名的 10 个,顺序照抄 |
| 11-13 | `missing_slots` `confirmation` `trace` | 本项目另加 3 个(缺什么槽 / 待确认什么 / 完整 Trace) |
`TurnResult.to_dict()` 是 10 个键,**信封是它的超集**;这个超集关系由
`build_envelope()` 里的断言钉住,不是口头约定。
**`route` 用引擎原样的四类**:`task` / `knowledge` / `chat` / `reject`。
契约里写的 `rag` 对应这里的 **`knowledge`**;`chat` 是引擎额外分出的问候/闲聊桶。
不在这里翻译成第二套词表 —— 翻译表只会漂,而且 `chat` 在那三个值里没有位置。
### SSE 事件
`turn_start` / `evidence` / `assistant_delta` / `citations` / `tool_call` /
`tool_result` / `final` / `done` / `error`。两条路线的实际序列:
| 路线 | 序列 |
|---|---|
| 任务 | `turn_start → tool_call → tool_result → final → done` |
| 知识 | `turn_start → evidence → assistant_delta* → citations → final → done` |
`final` 的载荷**就是**那个 13 键信封。`tool_call` 的 `call_id` 是
`f'{trace_id}:{index:02d}'`。
**SSE 不发独立的 `route` 事件** —— 最终 route 是 `knowledge` 还是 `reject`,要等 RAG
那次调用**有没有引用**才知道,流开始时还不知道,提前发只能是猜。route 由 `final` 如实带出。
---
## 5. 端到端演示
```bash
python -m scripts.phase7_demo # 自己起服务(真 uvicorn,端口 8010)
python -m scripts.phase7_demo --base-url http://127.0.0.1:8000 # 打你手上那个服务
python -m scripts.phase7_demo --limit 3 # 只看前三条
python -m scripts.phase7_demo --sse-only # 跳过 12 条,只演示 SSE 流
```
它跑的是 `data/evaluation/e2e_cases.json` 里那 **12 条**(计划书 §6.2 要求),
边走边讲:每条把「你说什么 / 引擎当成什么处理 / 调了哪个工具 / 车变成什么样 /
判了什么」逐行铺开,最后单独演示一次 `POST /api/v1/chat/stream`。
跑完不留任何东西(服务在退出路径上收掉;会话与车状态本来就只在那个进程的内存里)。
### 12 条的成果怎么读
**两个数字分开看,不合成一个"准确率":**
| | 结果 | 门槛 |
|---|---|---|
| **硬断言通过** | **12/12** | ≥ 9,达到 |
| **确认闸门**(独立) | **2/2** | = 2,必须 100% |
| 软断言(`route` / `skill` / 用了哪个工具 / 召回条数) | 如实记录 | **不参与通过与否** |
**为什么分两层**:`route` 和 `skill` 是**阶段 4 的模型数字**(技能 29/30、槽位 20/20),
而 `LLM_TEMPERATURE=0.2` 不是 0。把「这条是不是识别成 `media.play`」当闸门,等于让
收尾阶段替阶段 4 背书,还会随机变红。硬断言只压**路由无关的东西**:HTTP 200 + 13 键齐 +
`session_id` 回显 + `latency_ms > 0`;任务类**动作前后各打一次
`GET /api/v1/vehicle/state`** 比对真实字段;拒答类 `citations == []`;追问类答案里
没有「没能完成」;待确认时 `tool_calls == []`。
覆盖(对着 §6.2 那五类):知识 2 条(手册内 / 语料外)、拒识 1 条(超纲闲聊)、
任务 2 条(单轮动作 / 带槽导航)、追问 2 条(路由层缺槽 / **工具层追问**)、
确认 2 条(敏感操作 / 取消后不复活)、工具失败 1 条、状态一致 1 条、
范围边界 1 条(车外服务 → 拒识)。逐条明细见
[evaluation/end_to_end/reports/latest_e2e.md](evaluation/end_to_end/reports/latest_e2e.md)。
**这份报告自己说了它看不见什么**(§4):12 条里绝大多数判的是链路与状态,但
`tool_calls_nonempty`(e2e-04/05)和 `missing_slots_nonempty`(e2e-06/07)**实质上
是在要求"引擎必须走任务/追问这条道"** —— 那是这几条用例的考点本身,不是藏着的事;
另外**每条只跑一遍**,没有重复若干次取稳定值。
> **踩过的坑,留着给人看**:e2e-02(语料外问题)第一版写的是
> `answer_contains "【无法确认】"`,第一次真跑就红了 —— 引擎合法地走了 `reject`
> 那条路。第一次改成"路由二选一",读源码时发现**更错**:`route=='reject'` 还包含
> LLM 链路故障,那样模型一挂断言反而变绿(一次故障被记成一次合格的拒答)。
> 最终判据落在两个**声明的常量**上(`refusal_signal()` / `REJECT_ANSWER`),
> 与路由完全无关。
>
> 另外,这把尺子自己也被量过:`tests/e2e/test_e2e_cases.py` 用离线桩驱动同一份
> harness,跑 8 组**变异**(每个变异都必须让**指定的那一条**变红),外加一个
> 参数化测试直测「拒识断言认的是明说答不了,而不是路由或硬答」。
---
## 6. 本地实测性能
> **数字只从 [evaluation/end_to_end/reports/latest_perf.md](evaluation/end_to_end/reports/latest_perf.md) 抄。**
> 量于 2026-10-15,一次运行。
机器:AMD Ryzen 9 8945HX(16 核 32 线程)/ 31.2 GiB RAM / Windows 11 26200 /
Python 3.12.3 / `torch 2.14.1+cpu`(`cuda.is_available()=False`)/
GPU:RTX 5070 Laptop 8151 MiB(驱动 581.29)。
### 启动
| 这一次 | 起到 `/ready` 200 |
|---|---|
| 第一次启动 | 22.3s |
| 紧接着第二次启动 | 22.4s |
| 开精排(top_n=20) | 21.7s |
**这不是冷启动**(清系统页缓存要重启或特权工具)—— 表里的数都含磁盘 I/O,
**真冷启动只会更慢**。而且这一对"第一次/第二次"几乎一样(22.3 vs 22.4),
**没能**测出页缓存差别(上一次运行是 23.2 / 19.5)。所以准确的结论只有一条:
**本机启动稳定在 20 秒出头**。这 20 多秒里,BGE-M3 单独加载就占 14.87s。
### 检索耗时(`POST /api/v1/retrieve`,只检索不生成,取中位数)
| 档 | n | 中位数 | 最小 | 最大 | p90 |
|---|---|---|---|---|---|
| 精排关(默认档,`routes=['dense'] top_k=5 rerank_top_n=0`) | 12 | **462.6 ms** | 396.1 | 572.0 | 546.7 |
| 精排关(第二次启动后) | 12 | **408.6 ms** | 292.0 | 458.1 | 421.3 |
| 精排开(`rerank_top_n=20`) | 12 | **4220.8 ms** | 3621.9 | 6730.9 | 6616.4 |
精排那一档的**净增量**就是两行中位数之差。和它一起变的还有"服务启动时多加载了
一个 0.6B 的精排模型",两回事,别混着说。**这一档只看耗时,不看质量** ——
精确率是阶段 2/3 的事,结论在 `evaluation/rag/` 的报告里。
> 附录 B 那张表里 dense 单路的「平均延迟 395 ms」是**评测脚本自己量的**(30 题、
> 含打分流程),和这里的服务端中位数不是一个口径,**别拿两个数相减**。
### 整轮延迟(`POST /api/v1/chat` 的 `latency_ms`,按 route 分组)
| route | n | 中位数 | 最小 | 最大 |
|---|---|---|---|---|
| knowledge(检索 + 一次 LLM 生成) | 2 | **23573.5 ms** | 6072.6 | 41074.3 |
| task(裁决 + MCP 工具,不调 LLM) | 4 | **2626.9 ms** | 2043.1 | 2865.3 |
**这一档说明不了"首轮是否更慢"**:同一个 route 只有 2 条,而首轮那次恰好是两条知识题里
**快**的那次。要谈首轮代价,得把同一句话重复问若干次或按阶段拆开计时,这次没做。
### 内存 / 显存口径
服务进程**整棵树**(Windows 上 `.venv-reback\Scripts\python.exe` 只是个壳,真跑服务的是
它拉起的子进程;只量那个壳会读出 `0.0 GiB`,而 `0.0` 看上去不像报错):
| 时点 | 整棵树 RSS | 整棵树 USS |
|---|---|---|
| 刚 `/ready`,还没处理过请求 | **2137.7 MiB** | 882.3 MiB |
| 跑完检索与问答之后 | 2158.9 MiB | 902.3 MiB |
| 精排档跑完之后 | **2289.9 MiB** | 1279.9 MiB |
RSS = 进程名下物理内存(含共享页);USS = 只算它自己的(关掉它到底能还回多少)。
**Windows 没有 PSS**,那是 Linux 的指标。
两个模型各自**单独装一次**(进子进程量,`warmup()` 之后)—— 服务进程里
"谁占的"说不清,这个净增是"单独加载它一个"的代价:
| 模型 | 加载耗时 | 净增 RSS |
|---|---|---|
| BGE-M3(embedding) | 14.87s | **1748.9 MiB** |
| Qwen3-Reranker-0.6B | 11.53s | **1509.3 MiB** |
**LLM 不在这个进程里**,用 Ollama 自己的口径转述:
`qwen3.5:9b 6.1 GB 13%/87% CPU/GPU context 4096`。
它**不等于**任何进程的 RSS —— 显存那部分不在进程 RSS 里。**这两个 torch 模型没有显存
数字**:装的是 CPU wheel、`EMBEDDING_DEVICE=cpu`,它们没在用显卡。这不是漏了。
---
## 7. 已知限制
每条写「**现象 / 原因 / 影响面**」,不写成待办清单。
**① 「刚才那首再放一遍」会选错技能**
现象:路由到 `media.play`(应为 `media.previous`),且**三遍都错,不是抖动**。
原因:那句里的「刚才那首」需要窄范围对话上下文,而路由层只做**单轮**向量召回。
正确的技能其实就在候选里排第 1 —— 是模型在候选里挑错。影响面:§6.2 的 12 条用例
**都不经过它**;动它要改路由层的判定链,超出收尾阶段的范围(D-063)。
**② 单 worker / 内存态会话**
现象:多 worker 下「取消」可能落到另一个进程上,表现为"点了取消没反应",偶发难查。
原因:`DialogueStore` 与车辆状态都在进程内存里。**这是当前设计下不能,不是"没做"** ——
要横向扩得先把状态搬到 Redis / Mongo。影响面:`--workers 1` 是唯一成立的形态,
本报告全部数字都是单会话顺序打的(并发没测)。
**③ 裁决是召回白名单,不是模型选工具**
模型不挑工具:`SkillRouter` 挑技能、槽位过校验后由 MCP 调用。**任何地方都不许写成
Function Calling**(D-045)。影响面:拿"Function Calling"当关键词去对比这个项目会失真。
**④ 精排默认关,而且开关名和 `.env.example` 里那个不是一回事**
真正的旋钮是 **`REBACK_RERANK_TOP_N`(默认 0 = 关)**;`.env.example` 里的
`RERANK_ENABLED` **全仓库没有任何代码读它**(这句 `apps/api/settings.py:27` 自己已经写了)。
影响面:照 `.env.example` 去开精排会发现没反应。
**⑤ 启动要加载 BGE-M3(约 14.87s / 1.75 GiB)**
原因:技能路由本身要用它,所以它必然在 `/ready` 之前 —— `/ready` 因此不快;
`/ready` 自己**不加载任何模型**。影响面:冷启动 20 秒出头,`/ready` 在启动窗口里
可能返回 uvicorn 的纯文本 `Internal Server Error`(端口已在听、lifespan 还没跑完)。
**⑥ 车外信息服务不做**
天气、航班、订餐一律不接(D-042)。影响面:这是**有意的范围**,也是可断言的
—— e2e-12 就是问「帮我叫个外卖」,期望结果是拒识。
**另外两条设计使然的**:`TOOL_MODE=real` **未实现且写了会直接报错**(不静默回落成 mock);
确认/取消是**整句严格匹配**(偏保守,「要不就开吧」会被重问,D-049)。
---
## 8. 简历表述(实测数字 + 每个数字的边界)
**规则:每个数字后面必须跟一行边界**(哪次跑的、多少条、什么口径)。
数字一律从 `evaluation/*/reports/latest_*.md` 抄,**没跑过的一律不写**。
| 项 | 数字 | 边界 |
|---|---|---|
| 检索 | Gold **Hit@5 100% / Hit@1 80% / MRR 0.872**(dense 单路,395 ms) | 30 题人工核实题集;Milvus 混合检索,**精排关**;同一批题上"RRF 三路等权 + 精排 top-10"是 Hit@1 90% / MRR 0.944,代价是 8148 ms |
| 答案层 | 可答 **15/15**(结论正确且引用可定位)/无解题明确拒答 **4/5**/越界引用 **0** | 15 可答 + 5 不可答;可答题取自"dense 已 100% Hit@5"的那批 —— 考的是答案层,**检索给错证据的情形这批一次都没考到** |
| 技能路由 | **29/30**,三遍 `[29,29,29]` | 30 条独立表达,**全部改写成技能目录里找不到的说法**(第一版照着 `examples` 出题、20 条与索引逐字相同,整个数字作废);`temperature=0.2` 非 0 |
| 槽位抽取 | **20/20**,三遍 `[20,20,20]` | 20 条带槽用例;另有 2 个技能**一条都没考**(`media.playlist`、`vehicle.honk_flash`) |
| 多轮编排 | **10/10**;敏感操作确认/取消闸门 **5/5**;状态独立复核 32/32 | 10 条脚本;默认那条跑的是**桩路由 + 桩 RAG + 桩闲聊** —— 测的是编排层,**不是模型能力**;确认取消是确定性匹配,必须 100% |
| 端到端 | 硬断言 **12/12**(门槛 ≥9);确认闸门 **2/2** | 12 条,真 `uvicorn` 进程 + 真 HTTP;`TOOL_MODE=mock`;`route`/`skill` 是软断言不并入;每条只跑一遍 |
| 规模 | **26 技能 / 5 领域 / 35 槽位 / 24 工具 / 3 个 FastMCP Server** | 技能与工具数由 `phase4_skills` / `phase5_tools` 直接报出;技能目录另有 `catalog_hash=6ad1b75f034c9978` 可机器比对 |
| 测试 | **762** 项通过 | `pytest -q` 两次实测 61.29s / 66.91s,0 skip;**不是纯离线** —— 集成层那 11 条会真加载一次 BGE-M3(16.98s)并真调 Ollama;阶段 6 收尾时是 744 |
| 启动 / 内存 | 启动 **20 秒出头**(22.3s / 22.4s);服务整树 RSS **2.09 → 2.24 GiB**、USS **882 → 1280 MiB** | 本机一次运行,非冷启动;BGE-M3 单载 14.87s / +1748.9 MiB |
**明确不写的**:任何百分比提升、任何与外部方案的对比、计划书 §6.2 的「最低健康线」
(那是自查门槛,**不是成绩** —— 计划书自己写了"不把该健康线写成项目成绩")。
---
## 9. 环境备忘
- **本项目专属 venv 建在项目外**:`D:\pythonproject\.venv-reback`。
venv 与平台绑定,不要同步到服务器。
- **dev 环境跑 Python 要显式指那个 venv 的解释器**,并把 `PYTHONIOENCODING=utf-8`
设上 —— 服务的日志里有中文,Windows 默认编码会把它变成 `UnicodeEncodeError`
然后进程静悄悄地死掉,日志里什么都没有。
- **`soundfile` 是上游漏声明的强制依赖**。`qwen_agent/utils/utils.py:34` 在模块顶层
无条件 import 它,但它不在 `install_requires` 也不在任何 extra 里 —— 干净环境装完
`import qwen_agent` 直接崩,而 `pip check` 完全检测不到。已在 `requirements.txt`
显式补上。详见 [D-005](DECISIONS.md)。
- **CPU 版 torch 装的时候必须显式指 PyTorch 的 CPU 索引**,否则 pip 默认拉那个 2.5 GB
的 CUDA 包:
```bash
pip install --index-url https://download.pytorch.org/whl/cpu torch
```
本机显卡是 RTX 5070 Laptop(Blackwell / sm_120),将来要上 GPU 得换 cu128 的 wheel
—— 默认 cu126 的包装了也认不出这张卡,会在 kernel 调用时才炸(见 D-024)。
- **本机 pip 全局配置里有一个失效的 extra-index** `pypi.ngc.nvidia.com`,
每个包会先对它重试 5 次 SSL 才回落到正常镜像(实测单包 10.9s → 1.3s,8 倍)。
它写在**全局** pip 配置里,`--isolated` 挡不住。临时绕过:
```powershell
$env:PIP_EXTRA_INDEX_URL = "https://mirrors.aliyun.com/pypi/simple/"
pip install -r requirements.txt
```
- **git 全局配了 `http.proxy = http://127.0.0.1:7890`**,代理没开时任何 git 网络操作
都会挂死。本仓库已在 **repo 级**覆盖掉它(`git config --local http.proxy ""`),
所以这里 `git push` 直连即可。其他仓库若也受影响,用
`git -c http.proxy= -c https.proxy= ` 或同样加一句 `--local` 覆盖 ——
**不要再改全局配置**(代理开了的时候它是有用的)。
- **HuggingFace 本机不通,权重走 ModelScope**(`modelscope>=1.20`)。
---
# 附:分阶段评测明细
> 正文(第 1~9 节)是结论与口径;这里放各阶段的具体表格与逐条说明,
> **正文里的每个数字都能在这里或 `evaluation/*/reports/latest_*.md` 找到出处**。
## A. 阶段 1 · 文档解析与结构化
56 页 PDF(Model S Quick Guide EU,2013/2015)+ 12 页中文网页 → **603 块**
(165 父块 / 438 子块),137 张图片,13 个 document。验收自检 **5/5**:
每页有内容状态、每个 chunk 可回溯到页/URL(438/438)、原文与清洗文本并存、
重复执行不重复入库(`duplicate_content_hashes=0`)、图片无孤引用。
```bash
python -m scripts.phase1_ingest # 全跑,写库
python -m scripts.phase1_ingest --no-db # 只解析分块,不连数据库
```
## B. 阶段 2 · Hybrid Retrieval(30 题评测)
30 道人工核实过的题(20 PDF + 10 中文网页,其中 **11 道是「中文提问 → 英文证据」**),
5 套配置对照。完整报告见 [evaluation/rag/reports/latest.md](evaluation/rag/reports/latest.md)。
| 配置 | Hit@1 | Hit@5 | MRR | 平均延迟 |
|---|---|---|---|---|
| Dense 单路 | 80.0% | **100.0%** | 0.872 | 395 ms |
| Sparse-BGE 单路 | 56.7% | 66.7% | 0.611 | 367 ms |
| BM25 单路 | 26.7% | 40.0% | 0.319 | **4 ms** |
| RRF 三路等权 | 70.0% | 90.0% | 0.792 | 378 ms |
| RRF 三路等权 + 精排 top-10 | **90.0%** | **100.0%** | **0.944** | 8148 ms |
**请连着下面三行一起读,只抄表格第一行是没有意义的:**
- **等权三路融合比 dense 单路更差**(Hit@1 70% < 80%、Hit@5 90% < 100%)。
原因不是 RRF 写错了,是另外两路在跨语言那 11 道上各只中 1 道,等权融合时它们的
错误名次把 dense 的正确结果挤了下去。按 V2.0 §7 **没有去调权重**,如实记录。
- **精排有效但太慢**:Hit@1 70% → 90%,MRR 0.792 → 0.944,代价是慢约 20 倍。
故保持默认关闭、留作可选开关 —— 这和「精排没用」是两回事。
- **单路也各有一条线**:sparse_bge 同语言内 19/19、跨语言 1/11;BM25 的 `english`
analyzer 把连续汉字当**一个** token,中文查询基本零命中。
复现:
```bash
python -m scripts.phase2_eval_verify_gold # 金标机检:全过才允许跑评测
python -m evaluation.rag.run_retrieval_eval # 5 套配置 × 30 题,约 6 分钟
```
数字口径与结论见 [D-031](DECISIONS.md)。
## C. 阶段 3 · 有据 RAG 与引用(答案层 20 题)
命令行入口。**阶段 6 之后浏览器那个页面走的是同一条链路**(同一个 `RagPipeline`
和 `DialogueEngine`,只是外面套了 HTTP)—— 所以这里和页面上问同一句话,答案一样。
CLI 留着是因为它不用起服务,调 prompt / 换模型时比刷新页面快。
```bash
python scripts/phase3_ask.py "使用防滑链时车速不能超过多少?"
```
实测输出(本机,`qwen3.5:9b`):
```
使用防滑链时,车速不得超过 48 km/h [1]。
参考来源:
[1] Safety Information · EU · 2013/2015 · Safety Information > Tire Care and
Maintenance > Using Tire Chains · p26
```
中文提问 → 英文 PDF 取证 → 中文回答,引用落到**原 PDF 的第 26 页**。
证据不足时它会明确拒答,而不是编:
```
【模型明确拒答】
【无法确认】提供的证据仅包含关于轮胎更换、胎压检查及维护的信息,未提及发动机机油型号。
```
常用开关(完整列表见 `--help`):
| 开关 | 作用 |
|---|---|
| `-i` / `--interactive` | 交互式连续提问(模型只加载一次,后续每题快很多) |
| `--show-evidence` | 打印证据包里每一条的编号、来源、原文 —— 用来核对"它是从哪看到的" |
| `--show-prompt` | 打印实际发给模型的完整提示词 |
| `--dry-run` | 只检索、不调模型(几秒内出证据,调检索参数时用) |
| `--json` | 输出结构化结果(含 `citations` / `refused` / `warnings`),给程序读 |
| `--top-k` / `--budget-items` / `--budget-chars` | 检索条数与上下文预算 |
| `--routes dense` \| `--routes dense,sparse_bge,bm25` | 换召回组合(默认 `dense` 单路,理由见 [D-032](DECISIONS.md)) |
| `--config dense_rerank` | 换整套预设(含精排,慢约 20 倍) |
```bash
python evaluation/rag/run_rag_eval.py # 默认 dense 单路,约 2 分钟
python evaluation/rag/run_rag_eval.py --render-only evaluation/rag/reports/latest_rag.json
```
| 指标 | 值 | 门槛 |
|---|---|---|
| 可答题「结论正确 且 引用可定位」(逐题复核) | **15/15** | ≥ 10 |
| 无解题明确拒答 | **4/5** | ≥ 3 |
| 引用命中金标页 | 15/15 | — |
| 越界引用(编造编号) | 0 | — |
> **这个健康线不是项目成绩,读它之前先读报告第 1 节的两条限制。**
> 15 道可答题取自阶段 2 那批**在 dense 已 100% Hit@5 的条件下**挑出来的题 ——
> 这份评测考的是答案层,而它面前的问题在检索层已经被解掉了。
> **15/15 不能读成「系统能答对 15/15」**,只能读成「检索已给对证据时,答案层
> 15/15 忠实且可溯源」。检索给错证据的情形本批一次都没考到。
## D. 阶段 4 · 技能路由(30 + 20 题)
一句话 → **26 个技能**里的一个,并抽出槽位。5 个领域:导航 6 / 媒体 6 / 温控 5 /
车辆 5 / 充电与车辆状态 4。
> **范围:只做车内。** 计划书 §4.1 原本还列了 4 条天气技能(`weather.current` /
> `weather.destination` / `weather.forecast` / `weather.air_quality`),
> **2026-10-14 整体删掉了**。天气是车外信息,要真跑起来得再接一个外部天气服务,
> 属于加功能,不属于跑通主流程。删掉后 26 条仍落在 §4.1 允许的 25–35 区间内。
> 所以现在问「外面冷不冷」是**拒识**(`route-04`),而不是答一个度数。
```bash
python -m scripts.phase4_skills # 技能数量就是这里报的
python -m scripts.phase4_skills --list # 逐条列出来
python -m scripts.phase4_route "有点冷" # 路由一句
```
实测输出(本机,`qwen3.5:9b`):
```
候选(Top-5)→ 只把这 5 条交给模型:
1. climate.set_temperature +0.5670 设置空调温度
2. climate.set_fan +0.5467 调节风量
3. climate.set_mode +0.5020 空调模式与循环
4. climate.seat_heater +0.4998 座椅加热
5. navigation.search_place +0.4737 搜索地点
模型原始输出:
{"skill_id": "climate.set_temperature", "slots": {}, "missing_slots": ["value"],
"none_of_above": false, "reason": "用户表达车内温度过低需要调高,意图是设置
空调温度,但用户未明确说出具体度数"}
判定:climate.set_temperature(设置空调温度)
槽位:(空)
**缺必填槽,要追问**:['value']
value —— 目标温度(摄氏度),用户明确说出的度数。没给度数就留空。
```
两点值得看:
- 它**没有**把「有点冷」猜成 `value: 18`:只说了冷,没说几度,就留空并追问。
这是这条链路上最容易被违反的一条规则(见 `slot-03`)。
- 第 5 名 `navigation.search_place` 只有 +0.4737,比第 4 名低 0.026 ——
**候选列表的尾巴是噪声**。这很正常,只要正确的那条进了列表就行。
**诊断时第一步永远是先看候选,而不是先骂模型** —— 选错可能是召回没召上来
(要改索引),也可能是模型在候选里挑错(要改提示词),**两者处理方式完全不同**。
候选、模型原始输出、违规原因**每次都会打出来**(没有开关关掉它)。
```bash
python -m evaluation.routing.run_routing_eval --repeat 3 # 约 5 分钟
python -m evaluation.routing.run_routing_eval --render-only evaluation/routing/reports/latest_routing.json
```
| 项 | 结果 | 门槛 |
|---|---|---|
| 技能路由(30 条独立表达) | **29/30**,三遍 `[29, 29, 29]` | ≥ 20 |
| 槽位抽取(20 条带槽位) | **20/20**,三遍 `[20, 20, 20]` | ≥ 14 |
那一题错的是 `route-03`「刚才那首再放一遍」→ 选了 `media.play`(应为
`media.previous`)。**三遍都错,不是抖动**;正确的技能就在候选里排第 1,
所以是模型在候选里挑错,不是召回没召上来。**没有改期望值也没有删题** ——
按 §9.10「结果差就如实留着」。详见报告第 3.1 节与 §7 限制 ①。
完整报告见 [evaluation/routing/reports/latest_routing.md](evaluation/routing/reports/latest_routing.md)。
> **读这些数字之前,先读报告的 0 节和 0b 节。**
>
> **这套评测被推翻过一次。** 第一版用例集是照着技能目录的 `examples` 写的,
> 跑出 30/30 和 19/20 —— 但 `examples` 正是被编码进 `skill_index` 的文本,
> 30 条路由题里 **20 条逐字就是索引的一部分**。每道题在向量库里都有一个逐字相同的
> 双胞胎,那测的是"记没记住",不是"能不能泛化"。**整个数字作废。**
> 现在用例全部改写成目录里找不到的说法,并且**由机器查这件事**(报告 0 节),
> 而不是靠人想起来怀疑一下。这类污染不报错、不抛异常、数字还更好看。
>
> **之后分母又变过一次**(0b 节):删掉天气域后技能 30 → 26 条,用例里指向天气的
> 3 条也换掉了。**所以这份报告的分数不能和更早的报告直接比。**
>
> **还有 2 个技能一条都没考**(`media.playlist`、`vehicle.honk_flash`)——
> 它们可能坏着而这份报告看不出来。名单写在报告第 1 节。
>
> **健康线只用于发现链路是否明显失效,不是项目成绩**(计划书 §6.2 原文)。
> 29/30 离门槛 20 很远,但那是**这一套自拟题上的**水平,不是"还剩 9 条余量"。
## E. 阶段 5 · 任务执行与多轮对话
技能声明的 `tool:` 映射到 MCP 工具名,已校验的槽位拼成参数,经真实 MCP 协议调用。
**`TOOL_MODE=mock`**:车控/媒体/导航全是内存状态机,**没有接任何真实服务**。
```bash
python -m scripts.phase5_tools --list # 24 个工具、3 个 server
python -m scripts.phase5_tools --call vehicle.get_status # 从 shell 真发一次 MCP 调用
python -m scripts.phase5_chat -i --show-state # -i 才是主用法
```
多轮那几件事长这样(`-i` 里依次输入):
| 你说 | 会发生什么 |
|---|---|
| `打开后备箱` | 只**反问一句**「要打开后备箱吗?」,**这一轮不碰任何工具** |
| `确认` | 才真的执行;答话带「(Mock 模拟)」 |
| `算了` / `取消` | 什么都不做,且待办**清空** —— 之后再单独说一句「确认」不会把已取消的动作**复活** |
| `呃…这个` | 判定为含糊 → **重问,不执行**(整句严格匹配,D-049) |
| `充电上限设成 80%` | 同样要确认(§5.3 点名) |
| `放首歌` | `query` 是必填 → 追问「要听什么?」,你说「林岚」之后才执行 |
| `空调调到 99 度` | 工具**失败**,答话是「**没能完成:**温度只能设在 16–30℃ 之间…」,**绝不会说"已成功"** |
```bash
python -m evaluation.dialogue.run_dialogue_eval # 10 条,几秒钟
python -m evaluation.dialogue.run_dialogue_eval --repeat 3 # 确定性核对
python -m evaluation.dialogue.run_dialogue_eval --live # 换真模型端到端
```
| 项 | 结果 | 门槛 |
|---|---|---|
| §6.2 多轮脚本 | **10/10**,三遍 `[10, 10, 10]` | ≥ 8 |
| **敏感操作确认/取消(独立闸门)** | **5/5** | = 5(**必须 100%**) |
| 状态独立复核(`state_after`) | **32/32** | 每条每轮至少一次 |
| 工具调用第二本账(`ToolRuntime.call_counts`) | **14/14** | 逐条声明 |
完整报告见 [evaluation/dialogue/reports/latest_dialogue.md](evaluation/dialogue/reports/latest_dialogue.md)。
> **这份报告测的是"编排层",不是模型能力。** 默认那一条用的是**桩路由**
> (按脚本逐轮给出技能和槽位)+ 桩 RAG + 桩闲聊模型,所以它测的是「拿到"路由说了 X"
> 之后引擎到底干了什么」。**技能选得对不对是阶段 4 的数字**(29/30、20/20),
> 本阶段不重测;`--live` 才是端到端。
>
> **状态变化一律由另一个工具读回来复核**(`vehicle.get_status` / `media.get_state`,
> 它们**没有技能映射**,引擎永远不会自己选到)。动作工具自己的返回值是自证,
> §6.1 那条「工具声称成功但状态没有变化」正是靠这个抓的。
>
> **10/10 一次跑绿本身不构成"这份评测有效"的证据** —— 一个什么都不断言的检查器也是
> 10/10。所以 `tests/unit/test_dialogue_eval_harness.py` 里有五刀变异测试:砍掉确认闸门、
> 把失败说成成功、让待确认不弹出、让车状态谎报、把补槽消息装回错形状,跑分器必须
> **因为对的那一条**变红。第四刀证明 `state_after` 是真的在独立复核。
>
> **这五刀也划出了这份评测的边界。** 第五刀是**事后补的**:前四刀全绿的时候,真模型
> 那边**每一次补槽都在失败**(缺槽追问拼出了 `[system, assistant, user]`,qwen_agent
> 直接 400),而离线跑分器看不见 —— 因为桩模型只看提示词文本、不查角色顺序。
> **离线 10/10 不替代至少一次真实端到端试跑**,这条现在是代码里的一处检查
> (`RoleCheckedChat`)而不是一句口号。
## F. 阶段 6 · 统一 API 与 Web 演示(8 条验收)
阶段 6 没加新能力,只是把阶段 1~5 已有的东西组装成**本地可运行、可交互**的一套:
`FastAPI` 起一个进程,lifespan 里建**一次**共享运行时(Mongo / Milvus / Embedding /
LLM / SkillRouter / MCP / DialogueEngine 都不是每请求重建),`/` 挂一个无构建链的静态页。
演示页上那几条:答案流式渲染、引用卡片、可折叠 Trace(route / skill / slots /
检索结果 / 工具调用 / 耗时)、车辆状态面板、**「Mock 模拟,未连接真实车辆」横幅**、
待确认操作的确认/取消入口。
| # | 场景 | 结果 |
|---|---|---|
| 1 | 手册问答:有中文答案 + 可核验来源 | ✅ 「使用防滑链时,车速不得超过 48 km/h[1]。」,引用可定位到 `… > Using Tire Chains · p26` |
| 2 | 语料外问题:明确说依据不足 | ✅ `route=reject`、`citations == []`,**不编引用** |
| 3 | 「播放音乐」缺槽 → 追问 → 补上后成功 | ✅ 「放首歌」→ 追问歌手 → 「林岚」→ `media.control(query=林岚)`,状态复核 `track.artist == '林岚'` |
| 4 | 「打开后备箱」先确认后执行 | ✅ 第一次**零工具调用**,说了「确认」才调 |
| 5 | 取消一次敏感操作,车辆状态不变 | ✅ cancel → 再说「确认」→ 仍零工具调用,后备箱仍 closed |
| 6 | 操作后状态面板同步更新 | ✅ 用**另一个**端点 `GET /api/v1/vehicle/state` 读到 `closed → open` |
| 7 | 看一次完整 Trace | ✅ SSE:15 个 `assistant_delta` 拼起来**恰好等于** `final.answer` |
| 8 | 依赖不可用时页面明确报错而非伪造结果 | ✅ 把 `MILVUS_URI` 指到一个关着的端口起真服务:`/ready` **503 degraded**、`lost` 只说丢的是知识问答;提问得到「知识库这边还没就绪……」,**不编引用**;同进程的任务链路照常。`LLM_BASE_URL` 指到死端口时同样只有「问答模型」一项变红 |
**第 8 条是补验的。** 第一轮它没做(要真造一个坏依赖才看得见),当时记的是 ⚠️
而不是 ✅ —— 「没验过」和「验过了没问题」不是一回事。补验之后这条进了测试
(`test_acceptance_8_a_dead_dependency_degrades_instead_of_crashing`,7.8 秒,
`warmup=False` 所以不加载 BGE-M3),不再依赖「某天有人手工记得去关一次 Milvus」。
一~八都是在**真的 `uvicorn` 进程**上、通过 HTTP 打的,不是 `TestClient` 直连
(`tests/integration/test_api_live.py` 里另外那 11 条走 `TestClient`,会跑真 lifespan)。
**顺带修掉的一处体验问题**:工具缺参数以前被渲染成「**没能完成**:风量要调到几档?」
—— 追问长得像失败。现在工具结果是结构化的三态(`status: success | needs_input |
error`),`needs_input` 渲染成自然追问、`error` 才说执行失败、`success` 才允许声称完成。
**判 `status`,不判中文文案**(D-054)。追问之后**复用阶段 5 那一个** `PendingSlotFill`
接住下一轮,没有第二套补槽机制。
## G. 目录结构
按计划书 §10 组织([D-010](DECISIONS.md)):
```
reback/
├── apps/
│ ├── api/ # main / runtime / envelope / settings / streaming
│ │ # /health /ready /api/v1/chat(+ /stream) /retrieve
│ │ # /api/v1/vehicle/state /sessions/{id}/cancel
│ │ └── sse.py # 阶段 0 的 SSE 快照适配器(阶段 6 只加了三个事件常量)
│ └── web/ # 演示页:index.html + style.css + app.js,**无构建链**
├── configs/
│ ├── models/ # 模型与生成参数
│ ├── retrieval/ # 检索与 RRF / 阈值配置
│ ├── prompts/ # skill_route.md(路由 Prompt)
│ ├── skills/skills.yaml # 技能目录本体(26 个技能 / 35 槽位)
│ └── sources/ # pdf_sources.yaml / tesla_web_manifest.yaml
├── data/
│ ├── raw/ # 原始 PDF / 网页快照(不进公开仓库)
│ ├── processed/ # 解析与分块产物(不进公开仓库)
│ ├── evaluation/ # 评测集
│ └── fixtures/ # 测试夹具
├── src/
│ ├── ingestion/ cleaning/ chunking/ # 文档 → 结构化
│ ├── storage/ # MongoDB / Milvus
│ ├── retrieval/ reranking/ rag/ # 三路召回 → RRF → 精排 → 有据回答
│ ├── routing/ skills/ dialogue/ # 场景路由 / 技能目录 / 多轮状态
│ ├── tools/ mcp_servers/ # 工具与三个 FastMCP Server
│ ├── llm/ # 模型访问层
│ └── observability/ # 日志、Trace、指标
├── evaluation/{rag,routing,dialogue,end_to_end}/ # 评测脚本与报告
│ └── */reports/latest_*.md # 每个评测**不共用** latest.*(D-036)
├── scripts/ # check_env / phase1_ingest / phase2_build_index
│ # phase3_ask / phase4_route / phase5_chat
│ # phase7_demo / phase7_perf
├── tests/{unit,integration,e2e}/
├── vendor/qwen-agent/ # ⚠️ 上游源码快照(只读参考,不在 sys.path 上)
├── .env.example
├── PROJECT_STATUS.md
├── DECISIONS.md
└── requirements*.txt
```
`vendor/qwen-agent/` 里有两份必读文档:
- **[UPSTREAM.md](vendor/qwen-agent/UPSTREAM.md)** —— 快照从哪来(commit / 版本 /
日期)、裁掉了什么、为什么,以及"只能有一个来源"的约束。
- **[REUSE.md](vendor/qwen-agent/REUSE.md)** —— **复用对照表**:每个模块该抄哪个上游
文件、抄什么、换什么、哪些明确不要抄,以及 5 组带源码行号的踩坑记录。
## H. 进度
- [x] 拉取并裁剪 Qwen-Agent 源码快照 → `vendor/qwen-agent/`
- [x] 整理复用对照表 → [REUSE.md](vendor/qwen-agent/REUSE.md)
- [x] 目录骨架(计划书 §10)
- [x] 运行时保护 → [src/runtime_guard.py](src/runtime_guard.py)
- [x] SSE 快照适配器 → [apps/api/sse.py](apps/api/sse.py)
- [x] 环境检查脚本 → [scripts/check_env.py](scripts/check_env.py)
- [x] **阶段 0 收尾** —— serving 方式按 V2.0 §3.1 定为本地 Ollama,阻塞已解除
- [x] **阶段 1 文档解析与结构化** —— 56 页 PDF + 12 页网页 → 603 块,验收 5/5
- [x] **阶段 2 Hybrid Retrieval** —— 三路召回 / RRF / 元数据过滤 / Reranker / 调试接口
/ **30 题评测集与组合对比报告**(任务 8),验收 5/5
- [x] **阶段 3 最小可用 RAG 问答** —— LLM 统一配置 / Evidence Packing / 严格中文
Prompt / 结构化引用 / SSE 接入 / **命令行问答入口** / 20 题答案评测,健康线已达到
- [x] **阶段 4 技能目录与任务路由** —— 5 领域 **26 个技能** / 35 槽位、Skill Schema
与 Validator、`skill_index` 复用 BGE-M3、候选白名单校验、最多一轮结构修复、
**30 + 20 条用例**,健康线已达到(**评测中途被推翻重做过一次**,见附录 D)
- [x] **阶段 5 MCP 工具与多轮对话状态** —— **24 个工具**(3 个 FastMCP server)
经真实 MCP 协议可调用、四类基础路由、缺槽追问、敏感操作二次确认/取消、
工具失败如实报错,**10 条多轮脚本 10/10**、确认闸门 **5/5**
- [x] **阶段 6 统一 API 与简单 Web 演示** —— FastAPI 共享运行时(lifespan 建一次)
+ 7 个端点 + SSE 真增量 + 无构建链的静态演示页;`needs_input` 改成结构化第三态
(追问不再渲染成「没能完成」);8 条浏览器验收**全部通过**(第 8 条是补验的,
见附录 F)
- [x] **阶段 7 最终收尾** —— 12 条端到端用例(硬断言 12/12、确认闸门 2/2)、
演示脚本、本机性能实测与口径、README 重整 + 架构图、已知限制、简历数字
(全部来自报告)。**本阶段不加任何新功能。**
## I. 服务器(V2.0 起为可选)
> **V2.0 §1.2 / §4.2:服务器不是完成项目的前置条件。** 本地 MongoDB、Milvus、
> Ollama 和 Mock MCP 就是正式演示环境。下面这段只在以后想把项目搬上去时才需要看。
远端 AutoDL 数据盘目录 `/root/autodl-tmp/reback` 与本仓库一一对应
(数据盘 50 G 且持久;`/` 是 30 G overlay,重启会丢)。
实测要点(详见 [D-009](DECISIONS.md)):
- 4090 24 G,驱动 580.105.08,当前空闲;
- **HuggingFace 不通**,ModelScope 通 → 权重只能走 ModelScope;
- `python3` 不在 PATH 上,用 `/root/miniconda3/bin/python`(3.10.8);
- **未装 Docker**,按 [D-011](DECISIONS.md) 已从交付清单移除,改用等价部署脚本。