mcp-multi-agent-dispatch
README.md
# mcp-multi-agent-dispatch
**MCP 工具层 + LangGraph 三代理编排**:调度员说一句话,系统查队列、选机器人、算路径、派单、上报故障,写操作走人工审批,重复提交走幂等。
不是"多代理聊天演示":13 条端到端用例全部走**真 MCP 协议**(in-memory transport)和**真 LangGraph 状态机**,覆盖越权、状态冲突、信息不足、重复提交四类边界。
| 指标 | 实测 |
|---|---|
| 端到端用例通过率 | **13/13** |
| 平均步数(每条指令) | **3.69** |
| 工具调用总数(13 条用例) | **58 次**(执行 13 + 只读 45) |
| 越权拦截 | **2/2**(viewer 写操作、跨仓查询) |
| 澄清正确率 | **1/1**(缺任务号时不猜) |
| MCP 工具数 | 6(4 只读 + 2 写) |
| **提案采纳率** | **13/13 = 1.00** |
| **执行阶段重试倍数** | **1.00x**(没有一次动用 fallback) |
| **决策读放大** | **3.46 次只读调用 / 每个写提案** |
| **停滞断路器触发次数** | **0**(当前图是 DAG,本来就不会触发) |
### Agent 级指标:正例和负例必须分开算
| 代理 | 提案数 | 正例成功 | 负例正确拒绝 |
|---|---|---|---|
| AssignAgent | 7(正 2 / 负 5) | 1.00 | 1.00 |
| CollabAgent | 5(正 4 / 负 1) | 1.00 | 1.00 |
| PathAgent | 1(正 1 / 负 0) | 1.00 | — |
如果把正负例混在一起算"工具成功率",AssignAgent 会掉到 **0.17**——看上去像这个代理基本不工作。真实原因是 5 条负例(低电量、任务已被占用、越权)**本来就该被工具拒绝**,拒绝是正确行为。
**这也解释了为什么没做 token 成本与通信效率**:这个项目里 `planner` 是正则规则、三个代理是顺序函数调用,既没有 LLM 调用也没有 agent 间消息。那两项指标在这里没有定义,硬凑出来就是编的。
## 跑起来
```bash
pip install -e .
python -m dispatch_agent.cli tools # 列出 6 个 MCP 工具
python -m dispatch_agent.cli run "查一下 W1 排队的任务" --user U001
python -m dispatch_agent.cli run "把任务 T2002 派给机器人" --user U001
python -m dispatch_agent.cli run "R102 一级故障,马上处理" --user U002 --manual # 走人工审批
python scripts/eval_graph.py # 13 条端到端评测
```
`--transport direct` 可绕过 MCP 直调工具实现,只用于排查是"协议问题"还是"逻辑问题"。
**要求 Python ≥ 3.11**。人工审批那条路径(`--manual`)用 `interrupt()` 实现,它靠 ContextVar 取 `thread_id`;
把 context 传进工作线程的 `run_coroutine_threadsafe(context=...)` 是 3.11 才有的参数,3.10 上会在审批时抛
`Called get_config outside of a runnable context`。同步节点不受影响,所以症状是"别的都能跑,只有 `--manual` 挂"——
这是解释器版本问题,不是图写错了。原因与验证过程见 [docs/design.md](docs/design.md) §3。
## 架构
```
用户指令
│
▼
┌──────────────┐ 规则化解析:intent + slots + 缺失项
│ understand │ 缺参数 → clarify(不猜)
└──────┬───────┘
▼
┌──────────────┐ AssignAgent(单任务选车)
│ propose │ PathAgent(只读路径)
└──────┬───────┘ CollabAgent(批量/异常)
▼
┌──────────────┐ 只有 write=True 的动作进审批
│ review │ interrupt + checkpointer,断点可恢复
└──────┬───────┘
▼
┌──────────────┐ 失败按 fallbacks 换候选;步数上限 8;动作指纹去重
│ execute │
└──────┬───────┘
▼
finalize → 中文答复 + 每个工具的执行码
工具层(MCP):query_tasks / list_robots / get_battery / plan_path
assign_task / report_fault
鉴权 → 幂等 → 资源存在 → 业务规则 → 执行
```
## 三个关键设计
**① 授权在工具层,不在 Agent 层。**
Agent 可以"提议"任意操作,但工具自己决定执不执行。模型被诱导、Agent 写错逻辑,都越不过这条线。这也是为什么 `authorize()` 在 `_assign_task` 的第一行,而不是在 graph 里。
**② 写操作与只读操作共用一条图,只有写操作进审批。**
只读(查队列、算路径)直接执行,写(派单、报障)必须过 `review`。判断依据是 `Action.write`,不是"哪个 agent 调的"——同一个 agent 可能既查又写。
审批用 `interrupt()` + checkpointer:暂停时整个 state 被序列化进 checkpoint,恢复时从断点继续、**不重新执行 propose**。
但要分清机制和配置——**demo 配的是 `MemorySaver`,它活在进程内存里,真的重启进程状态还是丢**。所以准确说法是:
「机制上支持断点恢复;demo 的 checkpointer 是内存实现,**跨进程要换 Postgres checkpointer**」。这就是"能续跑"和"假装能续跑"的差别所在。
**③ 防死循环靠三道,但当前这张图其实用不上。**
| 防护 | 防什么 | 什么时候才真的生效 |
|---|---|---|
| 步数上限 `MAX_STEPS = 8` | 停不下来 | 加了回边之后 |
| 动作指纹去重(`tool + sorted(args)`) | 两个动作来回跳 | 同上 |
| **停滞断路器**(连续 2 次工具失败即停手) | 每轮参数都不同但一直在错 | 一个请求产生多个写动作时 |
**说清楚一件事**:当前图是 DAG(understand → propose → review → execute → finalize),没有回边,一个请求最多产生一个写动作,所以这三道在 13 条用例下一次都没触发——`stall_events = 0` 是正确结果,不是没实现。它们是给"加反思回边"预留的保险丝。
诚实一点讲这件事比硬说"我做了防死循环"更值:**能用代码证明它有效的前提,是先说清它什么时候才需要生效。**
## 业务规则(都在 `store.py`,改这里不影响编排)
| 规则 | 值 | 拒绝码 |
|---|---|---|
| 派单最低电量 | 20% | `BATTERY_TOO_LOW` |
| 电池健康度下限 | 80% | `ROBOT_FAULT` |
| 充电提醒阈值 | 25% | (只提示,不拦截) |
| 同一巷道作业上限 | 2 台 | `LANE_CAPACITY_EXCEEDED` |
| 图搜索步数上限 | 8 | `STEP_LIMIT` |
机器人与任务数据的口径见 `data/mock_business.json`:3 个仓库、11 台机器人、7 条任务、4 个角色。
## 评测怎么做的
`scripts/eval_graph.py` 里 13 条用例,每条**重置内存库**再跑(用例间状态污染是这类测试最常见的假通过来源),断言落在三处:
1. **最终回答**(是否说清"派给了谁""路径是什么")
2. **每个工具的返回码**(`BATTERY_TOO_LOW` / `UNAUTHORIZED` 这些不是异常,是业务语义)
3. **批内一致性**(一次派三单不能把同一台机器人派两次)
刻意设计的三类用例:
- **点名但不可用**:用户说"派给 R103",而 R103 电量 18% → 必须拒绝,**不能悄悄换成别的机器人**。所以点名时 `fallbacks` 为空。
- **越权**:viewer 派单、dispatcher 查别的仓 → 由工具拒绝,不是 Agent 拒绝。
- **重复提交**:同一条派单连跑两次 → 第二次返回 `IDEMPOTENT_REPLAY`,不重复下发给机器人。
### 三个指标的定义与局限
| 指标 | 定义 | 它能说明什么 / 不能说明什么 |
|---|---|---|
| **提案采纳率** 1.00 | 被执行的动作数 ÷ propose 产生的动作数 | 说明"提议的东西都真的去做了",没有生成完就丢;**不能**说明提议对不对(对不对看正/负例成功率) |
| **执行重试倍数** 1.00x | 执行阶段的工具调用数 ÷ 提案数 | 1.00 表示主方案一次成功、没动用 fallback;>1 说明候选选择质量下降 |
| **决策读放大** 3.46x | 只读调用数 ÷ 写提案数 | 每次派单平均要查 3.5 次队列/机器人/路径。这是**下一个优化点**:机器人状态缓存能把这块压到 1–2 次 |
⚠️ 这几个数都只来自 **13 条用例**,属于"能证明链路通、不能当性能基准"。`3.46` 这个数尤其不能外推——它完全取决于 `assign` 里查了几次、语料里几个仓几台车。
## 与真实系统的差异(别把它当成生产代码)
| 这里 | 生产里 |
|---|---|
| 规则化 `planner.py` 做意图识别 | LoRA 微调的 14B 意图分类(同一接口:intent + slots + 缺失项) |
| 内存态 `Store` + JSON mock 数据 | MySQL + Redis,工具层走真库 |
| 单机 in-memory MCP transport | 独立 MCP Server 进程,stdio/SSE 传输 |
| 审批用命令行 `input()` | 前端确认卡片 + 逐项批准(一次只能批一项) |
| 没有 LLM 参与决策 | 主 Agent 用云端模型规划,工具返回码驱动下一步 |
保留规则化解析是故意的:换成模型后,每个失败都会变成"模型没理解"这种不可定位的原因,评测数字就不可复现了。**先把编排和边界验证对,再换模型**。
## 目录
```
src/dispatch_agent/
models.py 枚举与数据结构
store.py mock 业务库 + 幂等表 + Dijkstra 路径
auth.py 工具级 + 仓库级双层鉴权
tools.py FastMCP 6 个工具(唯一能改状态的入口)
planner.py 规则化意图解析
agents/ AssignAgent / PathAgent / CollabAgent + 工具网关
graph.py LangGraph 状态机(含 interrupt 与防死循环)
cli.py 命令行入口
data/mock_business.json 3 仓库 / 11 机器人 / 7 任务 / 4 角色
scripts/eval_graph.py 13 条端到端评测
results/eval_report.json 实测报告
```
## License
MIT —— 见 [LICENSE](LICENSE)。`data/mock_business.json` 为虚构业务数据。
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues