deerflow-code/frontend-web/docs/multi-agent-backend-dev.md
2026-09-07 18:24:55 +08:00

1298 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 多智能体圆桌研讨 · 后端开发文档
本文档面向需要维护或扩展「多智能体圆桌研讨」功能后端的开发者。前端 API 速查见 `multi-agent-api.md`;前端实现见 `multi-agent-frontend-dev.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若未提供则当作待补文件)。
> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,**当前仓库未提供**这两份文件;Step 1(intent)与 Step 3(artifacts)的接口签名直接参考本页第 4 / 7 节即可。
源码内已有中文模块级注释(`app/gateway/routers/intent.py`、`recommend.py`、`multi_agent.py`、`artifacts.py` 及 `clarification_utils.py`),与本文互为补充。
---
## 0. 速读
- 三个圆桌 Router(`intent / recommend / multi_agent`)**通过 httpx ASGITransport 进程内直调本机 ASGI app**,复用既有 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream` 路由——**不走 TCP / 不经过网络栈**。所有 client 都由 `multi_agent._make_loopback_client(request)` 工厂统一构造。
- 历史包袱:早期实现走 `httpx.AsyncClient(trust_env=False)` + `127.0.0.1:<port>`,遇到开发机 `HTTP_PROXY` / `HTTPS_PROXY` 时仍可能把回环走代理(症状 `headers_in=42.160s`)。**改用 ASGITransport 后此问题不再存在**,相关 `trust_env=False` workaround 全部移除。
- **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体也是持久单例**(id 固定为 `roundtable-coordinator`,与 intent/recommender 同样走 `_roundtable_seed.ensure_roundtable_functional_agents()` 缺失自动落地)。2026-05 之前曾按 `roundtable-coordinator-{timestamp36}` 每会话新建并把动态席位列表写进 SOUL,导致 `.deer-flow/agents/` 累积大量壳目录;改造后 SOUL 是静态通用版本,「本次可调度席位列表」由 `multi_agent.init` 在 coordinator thread 创建后以 human 消息追加到对话历史,leader run 每轮都能看到。
- **`.deer-flow/` 整个目录被 git ignore**(仓库根 `.gitignore` 第 64 / 74 行),`git clone` 后内置 agent 目录在部署机上**不会存在**。但功能型 agent 现在通过 `_roundtable_seed.ensure_roundtable_functional_agents()` **自动落地**(首次任何 intent/recommend/multi-agent 路由调用都会触发),因此**新部署不再需要手动准备** `roundtable-intent` / `roundtable-recommender` / `roundtable-coordinator` 三个目录——它们都是 seeder 自动写盘的。**互联网 demo 仍需要 6 个示例研讨席位**(`roundtable-intelligence` 等),**内网部署用客户自有 agent 替代**这些席位,无需打包。详见 §11.7。
- **`init` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。
- **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。
- **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。
- **leader 共识 = `status: []` 空数组**;澄清 = `status: "clarification"`(字符串);派活 = `status: [[name, task], ...]`。这三者**互斥不混用**。
- **后端 picks clamp 2–6,前端用户可手动加到 8**。
- **不写死任何具体模型名**:`intent / recommend / multi_agent` 三个 router 的 `model_name = str(payload.get("model") or "")`,空串会被下游 `lead_agent._resolve_model_name()` 自动回退到 `config.yaml` 的 `models[0]`(与 `/api/ai-writing` 行为一致)。内网部署只需配 `config.yaml`,前后端均无需改硬编码。
- **草稿/推荐历史持久化(2026-06)**:圆桌页的「历史草稿」从浏览器 `localStorage` 迁到**后端 MySQL**(`/api/roundtable-drafts`,按登录用户隔离);新增**会话级推荐历史**让推荐弹窗回显上次结果。两张表 `roundtable_drafts` / `roundtable_recommend_history`,启动 `create_all` 自动建表,详见 §7A。
---
## 1. 功能定位与三步数据流
对应前端页面:`/page/workspace/roundtable/planning`(`RoundtablePlanningPage.tsx`)的三个步骤。
```
Step 1 任务意图理解
POST /api/intent/init → 建 thread,绑定内置 roundtable-intent
POST /api/intent/stream (多轮) → 澄清对话,终态 [INTENT_READY] + summary
↓ intentReady { objective, constraints, assumptions }
推荐弹窗(Step 1 → Step 2 之间,可选)
POST /api/recommend/stream → 从候选池选 2–6 个席位,[RECOMMEND_READY] + picks
↓ selectedAgents[] // 前端可再手动调整到 2-8
Step 2 多智能体圆桌研讨
POST /api/multi-agent/init (SSE) → 协调智能体 + 各席位 thread(asyncio.as_completed 流式推 seat_ready)
POST /api/multi-agent/run/stream → leader 派活 / special 执行 / 澄清
↓ lastLeaderContent、各席位交付、hasConsensus
Step 3 最终方案展示
GET /api/threads/{tid}/artifacts → 列出 outputs/ 产出文件
GET /api/threads/{tid}/artifacts/{path} → 预览或下载
PATCH /api/threads/{tid}/artifacts/{path} → 文本类产物原地编辑(可选)
(总控结论、席位文本主要来自 Step 2 前端内存缓存,一般不再调 run/stream)
```
| 步骤 | 前端 `currentStep` | 专属 Router | 内置 Agent |
|------|-------------------|-------------|------------|
| 1 | 1 | `intent.py` | `roundtable-intent`(固定 id,复用) |
| 1→2 | 推荐弹窗 | `recommend.py` | `roundtable-recommender`(每请求新建 thread) |
| 2 | 2 | `multi_agent.py` | 协调智能体(每次新建)+ 用户选定的 `roundtable-*` 席位 |
| 3 | 3 | `artifacts.py`(通用) | 无新 agent |
所有接口需 `Authorization: Bearer <JWT>`。Loopback 调用一律由 `_make_loopback_client(request)` 构造(`httpx.ASGITransport(app=request.app)`,base_url 占位 `http://loopback`),**不经 TCP**,因此不再受本机 `HTTP_PROXY` 干扰。
---
## 2. 涉及的后端文件
```
offline-backend-20260512/backend/
├── app/gateway/
│ ├── app.py # include_router: multi_agent, intent, recommend (artifacts 已存在)
│ └── routers/
│ ├── multi_agent.py # Step 2:init(SSE) + run/stream + 共享的 loopback 工具
│ ├── intent.py # Step 1:init + stream(import multi_agent 工具)
│ ├── recommend.py # Step 1→2:推荐 stream(import multi_agent 工具)
│ ├── artifacts.py # Step 3:列出/下载/编辑 thread 产出(通用,非圆桌专用)
│ └── roundtable_drafts.py # 持久化:草稿 CRUD + 推荐历史(§7A,2026-06 新增)
├── packages/harness/deerflow/persistence/roundtable_drafts/
│ ├── model.py # ORM:RoundtableDraftRow + RoundtableRecommendHistoryRow
│ ├── sql.py # RoundtableDraftRepository(CRUD + 历史)
│ └── __init__.py # make_roundtable_draft_store(sf)
├── packages/harness/deerflow/tools/builtins/
│ └── clarification_utils.py # resolve_allow_multiple + coerce_bool(Step1/Step2 共享)
└── .deer-flow/agents/
├── roundtable-intent/ # Step 1 任务理解(SOUL 含完整工作流,可不挂 Skill)
├── roundtable-recommender/ # 推荐智能体
├── roundtable-intelligence/ # 默认可选席位(6 个,前端可替换)
├── roundtable-environment/
├── roundtable-solution-design/
├── roundtable-risk-review/
├── roundtable-execution-plan/
├── roundtable-summary/
└── roundtable-coordinator-<id>/ # 协调智能体壳,每次会话新建,长期累积
```
**内置 agent 注册**:各 `roundtable-*` 目录通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次 `/api/agents` 请求时 upsert 进 `agents` 表,`user_id IS NULL` 表示内置。`recommend.py` 不依赖此表,前端会过滤 `roundtable-coordinator-*` 出候选池。
**Router 间共享导出**:`intent.py` 与 `recommend.py` 从 `multi_agent.py` import `_auth_headers / _create_thread / _flatten_content / _loopback_base / _parse_last_messages / _run_payload / _stream_upstream` 等底层工具,**不要**在 intent / recommend 重复实现 loopback 逻辑。
**共享工具逻辑**:`clarification_utils.py` 的 `resolve_allow_multiple(args)` 与 `coerce_bool(value, default)` 被 `intent.py` / `multi_agent.py` / `ClarificationMiddleware` 共用:
- `coerce_bool` 把 LLM tool args 里可能出现的 bool / 数字 / 字符串(`"true"`、`"1"`、`"yes"`、`"y"`、`"on"` / `"false"`、`"0"`、`"no"`、`"n"`、`"off"`)统一成 bool;
- `resolve_allow_multiple` 仅 `args["allow_multiple"]` 显式为真时返回 `True`,缺省 / `null` 都视为 `False`(单选)。
---
## 3. 接口总览
| 方法 | 路径 | 步骤 | 用途 |
|------|------|------|------|
| POST | `/api/intent/init` | 1 | 创建意图澄清 thread |
| POST | `/api/intent/stream` | 1 | SSE,一轮用户对话 |
| POST | `/api/recommend/stream` | 1→2 | SSE,一次性推荐席位 |
| POST | `/api/multi-agent/init` | 2 | SSE,**按完成顺序**推 `seat_ready`,最后 `init_done` |
| POST | `/api/multi-agent/run/stream` | 2 | SSE,leader 或 special 一轮 |
| GET | `/api/threads/{thread_id}/artifacts` | 3 | 列出该 thread 的 outputs 文件 |
| GET | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 预览/下载单个产出(含 `.skill/` 内文件提取) |
| PATCH | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 编辑文本类产物(白名单后缀) |
| GET | `/api/roundtable-drafts` | 持久化 | 列出当前用户草稿(轻量元数据) |
| POST | `/api/roundtable-drafts` | 持久化 | 新建草稿 |
| GET | `/api/roundtable-drafts/{id}` | 持久化 | 取草稿全量(含 step1/step2 快照) |
| PUT | `/api/roundtable-drafts/{id}` | 持久化 | 局部更新(title / furthest_step / step1 / step2) |
| DELETE | `/api/roundtable-drafts/{id}` | 持久化 | 删除草稿 |
| GET | `/api/roundtable-drafts/{id}/recommendations` | 持久化 | 列出该草稿的推荐历史(倒序) |
| POST | `/api/roundtable-drafts/{id}/recommendations` | 持久化 | 追加一次推荐记录 |
字段级请求/响应见各 step 的 API 文档;下文只写**后端实现语义**。圆桌草稿/推荐历史持久化见 §7A。
---
## 4. Step 1:任务理解(`intent.py`)
### 4.1 定位
轻量包装 Lead Agent 运行时,固定跑内置 `roundtable-intent`(`INTENT_AGENT_ID` 常量)。不做 broadcast、不派活;每轮用户消息对应一次 `/stream`。
### 4.2 `POST /api/intent/init`
1. `_create_thread()` → LangGraph thread
2. 返回固定 `agent_id: "roundtable-intent"`(**不**每次新建 agent)
请求体:`{"model": "deepseek-chat"}` — `model` 可选,**此处不写入 thread**,每轮 `/stream` 自己带 model。
与 Step 2 `/init` 的区别:无协调智能体、无 `agent_names` 列表。
### 4.3 `POST /api/intent/stream`
**入参**:`{thread_id, message, model?}`,缺 `thread_id` 或 `message` → 400。
**上游 run_payload**:见 §6.6;`excluded_tools` 屏蔽**除 `ask_clarification` 外的几乎全部工具**,硬清单:
```python
[
"web_search",
"present_files",
"view_image",
"agent_orchestration",
"bash", "ls", "read_file", "write_file", "str_replace",
"memory", "hindsight_recall", "hindsight_reflect", "hindsight_retain",
"task", "write_todos",
]
```
写死黑名单而不是「只允许 ask_clarification」的原因:SOUL 文字约束不可靠,硬排除更稳;防止模型偏离去写方案文档或派活。
**流式**:透传 LangGraph SSE → `_parse_last_messages(captured)` 从倒数 `values` 快照拿 `messages`。
**终态帧判定顺序(重要,勿调整)**:
1. **`[INTENT_READY]` 优先**:从最新消息向前扫描每条 AIMessage,正则 `_INTENT_READY_PATTERN = /\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```/s` 命中后 `json.loads` 解析
- 命中 → `status: "done"`,`summary` 为 JSON、`content` 为含标记的整段
- **优先于** `ask_clarification`:模型可能在较早的 AIMessage 里已 emit `[INTENT_READY]`,但本轮残留 `ask_clarification` 的 tool_call;不先扫 READY 前端会卡在澄清 UI
2. 否则用 `_find_dispatch_message(messages)` 找最后一条带 `tool_calls` 的 AIMessage,检查是否含 `ask_clarification`
- 命中 → `status: "clarification"`,附结构化字段:
```json
{
"status": "clarification",
"content": "<follow-up 或 preamble 或 question>",
"question": "...",
"clarification_type": "missing_info",
"clarification_context": "...",
"options": ["..."],
"allow_custom": true,
"allow_multiple": false,
"summary": null
}
```
- `content` 取 `_find_trailing_ai_text(messages, exclude_text=preamble)`:ClarificationMiddleware 常在 tool 后还有一条纯文本 AIMessage,比 tool_call 旁的短 preamble 更适合作为气泡正文
- `allow_multiple` 由 `resolve_allow_multiple(cargs)` 决定(缺省 false)
3. 否则 → `status: "asking"`,`content` 为 `_flatten_content(dispatch_msg.content)`,前端继续 chat
**异常**:`HTTPException` / 其它异常都返回 `{"status": "error", "content": "...", "summary": null}`,前端展示红色错误气泡。
**`summary` 形状**(`done` 时):
```json
{
"objective": "一句话目标",
"constraints": ["硬约束 1", "..."],
"assumptions": ["可选假设"]
}
```
### 4.4 内置 Agent:`roundtable-intent`
- 配置:`.deer-flow/agents/roundtable-intent/config.yaml` + `SOUL.md`
- SOUL 内写清:工作流、`ask_clarification` 用法、`[INTENT_READY]` 格式、禁止派活/写文件
- **可不配置 `skills`**;若配置 Skill,SOUL 要求不得偏离澄清与结束协议
---
## 5. 推荐智能体(`recommend.py`,Step 1 → 2)
### 5.1 定位
无状态单次调用:每次请求内部 `_create_thread()`,**不暴露 `/init`**。前端传入 Step 1 的 `intent` + 过滤后的 `candidates`(剔除 `roundtable-intent`、`roundtable-recommender`、历史 `roundtable-coordinator-*`)。
### 5.2 `POST /api/recommend/stream`
**入参校验**:
- `intent.objective` 必填(trim 后非空)
- `candidates` 必须是非空数组
- 至少一个有效 `agent_id`(trim 后非空)
- 三者缺一 → 400
**Prompt 拼装**(`_build_prompt`):
```
任务目标:<objective>
约束:<constraint1>; <constraint2>; ...
关键假设:<assumption1>; ...
候选智能体池:
- <agent_id> (<name>): <description>
- ...
请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。
```
格式刻意贴近 SOUL 示例,让模型有清晰的复刻模板。
**上游 run_payload**:`agent_name=RECOMMENDER_AGENT_ID`、`excluded_tools=["web_search", "present_files", "view_image", "agent_orchestration", "ask_clarification"]` — 纯判断,不调任何工具。
**终态分支**:
- 取**最后一条非空 AIMessage** 的文本(`last_ai_content`)
- 正则 `_RECOMMEND_READY_PATTERN = /\[RECOMMEND_READY\]\s*```json\s*(\{.*?\})\s*```/s` 匹配 picks JSON
- `_validate_picks(parsed.picks, candidate_ids)`:
- 跳过非 dict / 缺 `agent_id` / 不在候选池 / 已 seen 的项
- 至多保留 `_MAX_PICKS = 6` 个
- 不足 `_MIN_PICKS = 2` → 返回 `[]`,触发 fallback
- 有效 picks ≥ 2 → `status: "done"`,`picks` 有值
- 否则 → `status: "asking"`(**不是模型在追问用户**,而是「网关未能产出合法 picks」,前端走 FALLBACK 默认 6 席)
```json
{
"status": "done",
"content": "<自然语言推荐理由>",
"picks": [{"agent_id": "roundtable-risk-review", "reason": "..."}]
}
```
**picks 数量上下限的不一致**(需要心里有数):
| 边界 | 后端 | 前端弹窗 |
|------|------|----------|
| 下限 | `_MIN_PICKS = 2`(不足返 asking) | `MIN_SELECTED = 2`(确认按钮校验) |
| 上限 | `_MAX_PICKS = 6`(截断模型输出) | `MAX_SELECTED = 8`(允许用户手加 2 个) |
后端只对**模型输出**做 clamp,用户可手动追加候选池里其它 agent 到 8 个。
**前端流式展示**:自然语言理由与 JSON 由 `[RECOMMEND_READY]` 分界;`TitleMiddleware` 产生的标题增量会在客户端过滤(`langgraph_node` 含 `TitleMiddleware` / `tags` 含 `middleware:title`),避免污染推荐理由区。
### 5.3 与 SOUL 的契约
模型须在自然语言思路后输出:
```
[RECOMMEND_READY]
```json
{"picks": [{"agent_id": "...", "reason": "..."}, ...]}
```
```
`agent_id` 必须是候选池内已有的 id,`_validate_picks` 会做白名单校验。
---
## 6. Step 2:多智能体研讨(`multi_agent.py`)
### 6.1 `POST /api/multi-agent/init` — SSE,非一次性 JSON
```
请求体:
{
"agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
"main_agent_name": "<可选,已废弃>", // 历史字段;2026-05 改造后后端忽略,统一用 COORDINATOR_AGENT_ID
"model": "deepseek-chat" // 可选;不传由 lead_agent 回退到 config.yaml models[0]
}
```
**协调智能体改造说明(2026-05)**:以前 `main_agent_name` 由前端用时间戳生成
(`roundtable-coordinator-${Date.now().toString(36)}`),后端 `_create_coordinator_direct`
把动态拼接的席位列表 SOUL 写盘并入 DB,导致 `.deer-flow/agents/` 越用越多。改造后:
- **id 固定**:`COORDINATOR_AGENT_ID = "roundtable-coordinator"`(前后端常量必须保持一致);
- **缺失自动落地**:路由入口调 `ensure_roundtable_functional_agents()`,与 intent/recommender 共用同一套 seeder;
- **席位列表运行时注入**:init 创建好 coordinator thread 之后,立刻通过 `_append_thread_message` 把「【圆桌系统初始化】本次可调度席位:…」作为 human 消息追加到 thread 历史最前面,leader run 每轮都能看到;
- **`main_agent_name` 入参保留为向后兼容**:旧 client 仍可传,后端只是日志里看到不会用它创建新 agent。
**早期校验**(同步抛 HTTP 4xx,**不**进 SSE):
- `agent_names` 必须非空
- 每个 `agent_names` 元素需通过 `_AGENT_ID_PATTERN = ^[A-Za-z0-9_-]+$`(用 `_validate_agent_id` 提前 400;上游若收到不合规 id 会抛 422,提前拦更友好)
- 所有 id 都 `.lower()` 后比较 / 存储
- `main_agent_name` 入参不再做校验(被忽略,统一用 `COORDINATOR_AGENT_ID` 常量)
**SSE 内部流程**(2026-05 单例改造后):
```
ensure_roundtable_functional_agents()
// 路由入口:保证 .deer-flow/agents/roundtable-coordinator/ 存在
// 缺失时由 _roundtable_seed.COORDINATOR_AGENT_SOUL 落地静态 SOUL
asyncio.as_completed(各席位 _prepare_sub_agent)
每完成一个 → data: {"event":"seat_ready","agent_name","thread_id","display_name","description"}
(按真实完成顺序,先完成先发,前端因此能逐个点亮)
异常时:取消所有未完成的 task,避免泄露到 AsyncClient close 之后
协调智能体(单例):
main_agent_name = COORDINATOR_AGENT_ID # 固定常量,不再每次新建 agent
coord_thread = _create_thread_direct(...) # 仍然每次新 thread,会话独立
agent_threads[main_agent_name] = coord_thread // ⚠️ thread_ids 含 coordinator 自身
# 通过 HTTP loopback append 一条 human 消息,把本次席位清单写入对话历史:
# "【圆桌系统初始化】本次研讨可调度的席位清单如下..."
# leader run 每轮都能从对话历史里读到,等效于旧版动态 SOUL 的作用。
_append_thread_message(seed_client, ..., coord_thread, seat_introduction)
→ data: {"event":"coordinator_ready","main_agent_name","thread_id"}
→ data: {"event":"init_done","main_agent_name","thread_ids":{...}}
失败 → data: {"event":"error","detail":"..."}
```
**协调智能体单例 SOUL**(磁盘上唯一一份,见 `_roundtable_seed.COORDINATOR_AGENT_SOUL`):
通用协调规则,**不含**席位列表。模型派活时从对话历史最前面的「【圆桌系统初始化】…」消息读取本次可用 agent_name。仍然强调英文 id(避免把中文显示名拼进 `agent_name`),靠后端 `_strip_display_suffix` 兜底(详见 §6.4)。
**席位清单注入消息形状**(init 阶段写入 coordinator thread):
```
【圆桌系统初始化】本次研讨可调度的席位清单如下,请严格按 agent_name 通过 agent_orchestration 技能派活,不要派给不在清单内的 agent:
- agent_name: roundtable-intelligence(情报收集):<description>
- agent_name: roundtable-environment(环境评估):<description>
...
```
中文显示名通过 `_get_agents_batch_direct` 一次 DB 查询取 `name`、描述取 `description`。
**单例 agent 的几个语义保障**:
1. **agent 落地幂等**:`_ensure_one` 检查目录是否存在,存在就 no-op,不覆盖运维手改的 SOUL;
2. **席位列表 append 失败不阻断**:`_append_thread_message` 包在 try/except 里,失败只记 warning,leader run 仍能跑(只是看不到席位清单,可能派活到错的 id,由 `_validate_agent_id` 在 run/stream 里报错);
3. **`_create_coordinator_direct` 已删除**:旧函数已移除(2026-05 改造),避免后续维护者误调;如果你正在 review 旧 PR 看到这个函数,那是 pre-rebase 版本。
### 6.2 `/run/stream` — `agent_type = "leader"`(`_leader_run`)
```
1. 并行启动 pre_broadcast_task(不 await):向除自己外所有 thread 追加
"总控智能体收到用户消息:<msg>" human 消息
2. _run_payload + _stream_upstream(POST /api/threads/{coordinator}/runs/stream)
- excluded_tools: ["web_search"] // 只禁联网,其余工具(含 agent_orchestration)保留
- skill_stop_names: {"agent_orchestration", *callers_extra}
// 网关自动并入 agent_orchestration,让 SkillStopMiddleware 在工具调用后停图,
// 便于本路由从 AIMessage.tool_calls 解析派活列表
3. 透传上游 SSE,_parse_last_messages 拿 messages
4. await pre_broadcast_task(LLM 通常已跑完,这里 await 接近 0 成本)
5. _find_dispatch_message(messages) 找带 tool_calls 的 AIMessage
6. 分三支:
分支 A — ask_clarification:
{"status": "clarification", "content": follow_up 或 question 或 leader_text,
"question", "clarification_type", "clarification_context",
"options", "allow_custom", "allow_multiple", "agent_name"}
⚠️ 空 dispatched 不等于共识,前端展示澄清卡片并暂停自动派活循环
分支 B — 有 tool_calls(派活):
_extract_dispatch(tool_call)解析每个 tool_call → (sub_name, task_text)
并行 _broadcast("总控智能体给 X 派活,内容为:Y", skip=[leader, sub_name])
等所有派活广播完成(asyncio.gather)
{"status": [[sub_name, task_text], ...], "content": leader_text, "agent_name": agent_name}
分支 C — 无 tool_calls:
{"status": [], "content": leader_text, "agent_name": agent_name}
// 共识达成,前端 hasConsensus = true
```
**`_extract_dispatch` 双格式支持**:
1. **结构化**:`{"name": "agent_orchestration", "args": {"agent_name": "...", "task": "..."}}`
— 大多数 LLM 在 SOUL 引导下走这条
2. **Legacy bash argv**:`{"name": "bash", "args": {"command": '... .py "X" "Y" ...'}}`
— 正则 `_TOOL_CALL_PATTERN = /\.py\s+"([^"]*)"\s+"([^"]*)"/`,兼容旧 skill;不可移除
两条路径都会过 `_strip_display_suffix`:用 `re.split(r"[^A-Za-z0-9_-]", agent_name, maxsplit=1)[0]` 截断第一个非合法 id 字符之后的内容,把 `roundtable-x(中文)` 还原为 `roundtable-x`;剥离后若仍违规,原样返回让上游 422。
**时序日志**(grep `[leader-timing]`):
```
payload_built — 构造请求体耗时(broadcast 已经在后台跑)
first_upstream_line — 上游首行(LangGraph 启动 + LLM 首字节)
upstream_stream_complete — 整条 SSE 透传完成
pre_broadcast_await — 必须 await 才能 yield 终态;>0 表示广播比 LLM 还慢
dispatch_broadcasts_await — 派活广播并行 gather 耗时
```
### 6.3 `/run/stream` — `agent_type = "special"`(`_special_run`)
```
1. _run_payload + _stream_upstream(POST /api/threads/{sub}/runs/stream)
- excluded_tools: ["web_search", "ask_clarification", "present_files",
"view_image", "agent_orchestration"]
// 子智能体禁派活/澄清/文件呈现/图片;只做交付
2. 透传 SSE,_parse_last_messages 拿最后一条 AIMessage 的 content
3. 启动 post_broadcast_task("子智能体 X 完成 Y 工作,交付内容为:..."),不 await
4. yield {"status": "子智能体X完成X工作", "content": "<交付正文>", "agent_name": "..."}
// ⚠️ 在 yield 之后再 await broadcast,让前端先收到终态、关 loading
5. await post_broadcast_task(SSE 连接稍微多保持一会;前端早就拿到结果了)
```
**时序日志**(grep `[special-timing]`):
```
payload_built
first_upstream_line
upstream_stream_complete
post_broadcast_total — yield 之后 await broadcast 的总耗时
```
### 6.4 `_broadcast` 与 `_append_thread_message` 机制
```python
async def _append_thread_message(client, base, headers, thread_id, content):
# GET /api/threads/{tid}/state → state
# state["values"]["messages"].append({"type": "human", "content": content})
# POST /api/threads/{tid}/state
# 失败仅打 warning,不阻断主流程
async def _broadcast(client, base, headers, message, agent_threads, skip):
targets = [tid for name, tid in agent_threads.items() if name not in skip]
await asyncio.gather(*(_append_thread_message(...) for tid in targets),
return_exceptions=True)
```
**目的**:各 thread 对话历史里保留「谁派活、谁交付」的群聊上下文;**`skip` 通常含发起方与接收方**,避免回声。
**三种触发**:
| 触发点 | 内容 | skip |
|--------|------|------|
| leader pre_broadcast | `总控智能体收到用户消息:<msg>` | `[leader]` |
| leader 派活 | `总控智能体给 X 派活,内容为:<task>` | `[leader, sub_name]` |
| special 后广播 | `子智能体 X 完成 Y 工作,交付内容为:<content>` | `[sub]` |
**注意**:广播是 best-effort,对方 LLM 真正干活仍需前端显式 `/run/stream` 调到对应席位。
### 6.5 终态帧协议(leader / special / clarification)
前端 `streamMultiAgent` 用 `isFinalStatusFrame()` 识别:**同时存在 `status` 与 `content`**。
| `status` 类型 | 含义 |
|---------------|------|
| `[["agent","task"],...]` | 派活列表(数组);`[]` = 共识 |
| `"clarification"` | 总控追问,需用户回复后再 leader |
| `"子智能体 X 完成..."` | special 完成(字符串) |
| `"error"` | 异常 |
与 Step 1 的 `clarification` 帧字段对齐(`question`、`options`、`allow_multiple` 等),便于复用选项卡片组件。
### 6.6 `_run_payload` 完整字段
```python
{
"input": {"messages": [{"type": "human",
"content": [{"type": "text", "text": new_message}],
"additional_kwargs": {}}]},
"config": {"recursion_limit": 1000},
"context": {
"agent_name": <coordinator 或 sub agent id>,
"model_name": "deepseek-chat",
"mode": "pro",
"reasoning_effort": "medium",
"thinking_enabled": True,
"is_plan_mode": False,
"subagent_enabled": False,
"thread_id": <thread_id>,
},
"stream_mode": ["messages-tuple", "values"],
"stream_subgraphs": True,
"stream_resumable": True,
"assistant_id": "lead_agent",
"on_disconnect": "continue",
"excluded_tools": <按 router 不同>,
"skill_stop_names": <leader 强制并入 agent_orchestration>,
}
```
字段含义:
- `recursion_limit: 1000` — 防止 LangGraph 循环深度兜底(圆桌实际很难达到)
- `thinking_enabled: True` — 开启模型的扩展思考(CoT),需 model 支持
- `is_plan_mode: False`、`subagent_enabled: False` — 圆桌内禁用 TodoList / subagent 中间件,避免与多智能体协调冲突
- `stream_mode: ["messages-tuple", "values"]` — 同时需要逐 token 增量与最后一次完整 values 快照
- `assistant_id: "lead_agent"` — 走 Lead Agent 工厂
- `on_disconnect: "continue"` — 客户端断连后上游继续跑完(不丢 token 但客户端拿不到)
### 6.7 上游 SSE 透传与 `_parse_last_messages`
`_stream_upstream` 逐行 yield 上游 `/api/threads/{tid}/runs/stream` 的 SSE。返回元组:
```
(line, None) — 原样转发给浏览器(含 event: / data: 行)
(None, captured) — 流结束,captured 是所有 data: 行的字符串列表
```
日志 `[upstream-timing] <agent> headers_in=<>` / `first_line=<>`:排查上游首字节延迟;改 ASGITransport 后通常 `headers_in` < 10ms,长尾大概率出在路由 handler 内部(不再是网络/代理问题)。
`_parse_last_messages(captured)` 从倒数往前扫,**跳过 trailing `data: null` end 帧**,找第一条解析为 dict 且含 `messages` key 的 frame;取 `messages` 列表。无可解析帧抛 `502`。
### 6.8 三个 AIMessage 搜索辅助
| 函数 | 用途 |
|------|------|
| `_find_dispatch_message(messages)` | 从后向前找带 `tool_calls` 的 AIMessage(跳过 SkillStop / Clarification 注入的 ToolMessage);都没有则返回最后一条 AIMessage |
| `_find_trailing_ai_text(messages, exclude_text)` | 从后向前找**无 tool_calls** 的 AIMessage 的纯文本(用于澄清后的 follow-up,排除等于 preamble 的项) |
| `_flatten_content(content)` | 把 `str` / 结构化 content blocks `[{"text": ...}]` 拍平成单串 |
---
## 7. Step 3:产出文件(`artifacts.py`,通用 Thread API)
Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力,圆桌只是消费者之一。
### 7.1 `GET /api/threads/{thread_id}/artifacts`
- 鉴权:`@require_permission("threads", "read", owner_check=True)`
- 扫描:`get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id())`
- 解析为 `.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/`
- `collect_outputs_listing(outputs_dir)`:
- 递归 `rglob("*")`,过滤非文件
- 软链接安全检查(`resolved.relative_to(outputs_root)`,越界丢弃)
- 按 `modified_at` 倒序
- 目录不存在 → `files: []`(**非 404**)
```json
{
"thread_id": "...",
"files": [
{
"path": "mnt/user-data/outputs/report.md",
"name": "report.md",
"size_bytes": 12480,
"mime_type": "text/markdown",
"modified_at": "2026-05-23T14:32:30.123Z"
}
]
}
```
前端 `listSessionArtifacts(threadId)` 封装此接口;Step 3 对协调 thread 与各席位 thread 分别拉取,合并为下载清单(实际只列子席位)。
### 7.2 `GET /api/threads/{thread_id}/artifacts/{path}`
- 鉴权同上
- 虚拟路径前缀 `mnt/user-data/outputs/...`,经 `resolve_thread_virtual_path` 转物理路径并防穿越
- **特殊:`.skill/` 压缩包透视**:若 `path` 含 `.skill/`,从 ZIP 提取内部文件(如 `xxx.skill/SKILL.md`)。`_extract_file_from_skill_archive` 同时支持「直接路径命中」与「带顶层目录前缀」两种压缩包结构。结果带 `Cache-Control: private, max-age=300` 头避免重复解压
- `ACTIVE_CONTENT_MIME_TYPES = {"text/html", "application/xhtml+xml", "image/svg+xml"}` 始终强制 `Content-Disposition: attachment`(防止 XSS 与脚本执行)
- 文本类(mime 以 `text/` 开头)→ `PlainTextResponse`
- 后缀判定不到、但 8KB 内无 null 字节 → 当文本返回(`is_text_file_by_content`)
- 其它 → `FileResponse` 或 `Response` 内嵌
- `?download=true` 对所有类型强制附件下载
### 7.3 `PATCH /api/threads/{thread_id}/artifacts/{path}`
- 鉴权:`@require_permission("threads", "write", owner_check=True)`
- 请求体:`{"content": "<新内容>"}`
- 限制:
- `.skill/` ZIP 内文件只读(400)
- `ACTIVE_CONTENT_MIME_TYPES` 拒绝(400 active artifacts are read-only)
- 文件后缀需在 `EDITABLE_TEXT_SUFFIXES = {.md, .markdown, .txt, .json, .yaml, .yml, .csv, .xml, .log}` 之内,**或** mime 以 `text/` 开头
- 用 UTF-8 覆写原文件,返回 `PlainTextResponse(body.content)`
圆桌 Step 3 当前主要消费 GET,PATCH 用于「在线修订交付稿」类场景,前端默认未启用。
### 7.4 与 Step 2 内存的关系
**总控结论、各席位最终段落**:主要来自 Step 2 前端缓存的 `lastLeaderContent`、`step2RoundtableDialogues`(filter `confidence === "子智能体交付"`),**不**为 Step 3 新增聚合接口;如后续需要后端聚合,可加 `sessions/summary` 草案路由。
---
## 7A. 圆桌草稿与推荐历史持久化(`roundtable_drafts.py`,2026-06)
圆桌规划页的「历史记录」此前只存浏览器 `localStorage`(前端 `utils/drafts.ts`),换设备/清缓存即丢、大对话还会撞 5MB 配额。2026-06 迁到**后端 MySQL**,按登录用户隔离、跨设备保留;并新增**会话级推荐历史**,让推荐弹窗能回显上次推荐结果。
### 7A.1 数据模型(`deerflow.persistence.roundtable_drafts`)
仿 `user_prompts`(最简 CRUD)+ `ai_writing_sessions`(大 JSON 用 `PortableLongText` 存)两个现有模式。两张表:
- **`roundtable_drafts`**(会话主表,一条 = 一次完整圆桌会话)
- `id`(PK, str64) / `user_id`(idx) / `title`(512) / `furthest_step`(int, 1|2|3)
- `step1` / `step2`:前端 `DraftStep{1,2}Snapshot` 的 JSON,列类型 **`PortableLongText`**(MySQL `LONGTEXT`),避免 Step 2 长对话 + `seatStubMessages` 撞 `TEXT` 64KB 上限
- `created_at` / `updated_at`:**`BeijingDateTime`**(项目规范,存北京时间且 tz-aware)
- **`roundtable_recommend_history`**(推荐历史,按 `draft_id` 归属)
- `id`(PK) / `user_id`(idx) / `draft_id`(idx) / `objective`(512) / `status`(32: done|fallback|asking|error) / `model`(128)
- `rationale` / `picks`(JSON) / `candidates`(JSON 候选池快照):均 `PortableLongText`
- `created_at`:`BeijingDateTime`
JSON 列在 repo(`sql.py`)里用 `json.dumps/loads` 手动序列化(与 `ai_writing_sessions.transcript` 同法)。两个 Row 已登记进 `persistence/models/__init__.py`,**启动时 `Base.metadata.create_all` 自动建表**——新部署无需手动建表(需 mysql 账号有建库/表权限)。
### 7A.2 路由(`app/gateway/routers/roundtable_drafts.py`,前缀 `/api/roundtable-drafts`)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `` | `list_drafts` → 轻量元数据列表(不含 step 大 blob),按 `updated_at` 倒序 |
| POST | `` | `create_draft`,可带客户端 `id`(稳定 id 跨 create+update) |
| GET | `/{id}` | `get_draft` 全量;非属主 → 404 |
| PUT | `/{id}` | `update_draft`,`model_dump(exclude_unset=True)` → 只更新客户端实际发的字段 |
| DELETE | `/{id}` | 204;非属主 → 404 |
| GET | `/{id}/recommendations` | 推荐历史倒序 |
| POST | `/{id}/recommendations` | 追加一次推荐(先校验草稿属主,404 拦截越权) |
鉴权/属主:用 `_current_user_id(request)`(登录态取 `request.state.user.id`,无登录回退 `get_effective_user_id()` 的 `"default"`),与 `user_prompts` 一致;所有 repo 读写按 `user_id` 过滤,跨用户访问一律 404。store 在 `deps.py` 接为 `app.state.roundtable_draft_store`(`make_roundtable_draft_store(sf)`),`app.py` `include_router`。
### 7A.3 与前端的契约要点
- **PUT 不带 `title` 时不改标题**:前端自动保存只发 `furthest_step`/`step1`/`step2`,不发 `title`,因此**不会覆盖用户手动重命名**;标题只在 create(派生)与显式重命名(只发 title)时改。
- **推荐时机**:前端在打开推荐弹窗前先 `ensureDraft` 落地草稿拿到 `draft_id`,推荐完成后 POST 一条历史;重开弹窗 GET 历史回显最近一次,可「重新分析」再 POST。
- **字段命名**:后端 snake_case(`furthest_step`/`created_at`),前端 `api/drafts.ts` 在边界映射成 camelCase。
### 7A.4 设计取舍
- **整存 JSON 而非拆表**:step1/step2 形状宽且常变(前端 hook 里是 loose 类型),整存 JSON 避免每次改前端快照都要迁库;代价是后端不校验嵌套字段(与 `ai_writing_sessions.transcript` 同取舍)。
- **推荐历史绑定草稿而非全局**:语义是「这个会话上次推荐了什么」,故按 `draft_id` 归属;这也要求推荐前必有 `draft_id`(前端 `ensureDraft` 保证)。
- **本地 dev 走 sqlite**:`config.yaml` `database.backend` 本地默认 sqlite,走同一套 SQLAlchemy 代码,`PortableLongText/JSON` 各自降级;生产切 mysql 用同一份代码。
---
## 7B. 业务链条持久化(`roundtable_chains.py`,2026-06)
「业务链条」是用户在配置页编排的**可复用、命名、有序**的智能体编排模板;选中后 Step 2 由总控**按链条顺序派活**(前端编排,后端零改动)。链条持久化仿 §7A,新增一张表 + 一组 CRUD 路由。完整前后端设计见 `frontend-web/docs/multi-agent-business-chain-dev.md`。
### 7B.1 数据模型(`deerflow.persistence.roundtable_chains`)
- 表 **`roundtable_chains`**:`id`(PK str64) / `user_id`(idx) / `title`(512) / `description`(512, nullable) / `seats`(**`PortableLongText`** JSON:有序 `[{agent_id,name,description}, ...]`,`seats[0]` 先发言) / `created_at` / `updated_at`(**`BeijingDateTime`**)。
- ORM `RoundtableChainRow` 登记进 `persistence/models/__init__.py`,启动 `Base.metadata.create_all` 自动建表;store `make_roundtable_chain_store(sf)` 接到 `app.state.roundtable_chain_store`(`deps.py`)。
### 7B.2 路由(`app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `` | 当前用户全部链条(含 seats,按 `updated_at` 倒序) |
| POST | `` | 新建(可带客户端 `id`);校验席位 `2 ≤ N ≤ 8` + 去重 |
| GET | `/{id}` | 取单条;非属主 → 404 |
| PUT | `/{id}` | partial 更新(`exclude_unset`);带 seats 时同样校验 |
| DELETE | `/{id}` | 204;非属主 → 404 |
- 鉴权/属主用 `_current_user_id(request)`,跨用户一律 404,与 `roundtable_drafts` 一致。
- 席位约束 `MIN_SEATS=2 / MAX_SEATS=8` 与前端 `CHAIN_MIN/MAX_SEATS`、推荐弹窗 `MIN/MAX_SELECTED` 对齐;前端配置页保存前先校验,后端 `_validate_seats` 兜底(400)。
- 前端 `api/chains.ts` 直接打这组路由(snake↔camel 边界映射);测试 `tests/test_roundtable_chains.py`(CRUD + 部分更新 + 跨用户隔离)。
---
## 8. 内置席位 SOUL 设计要点
每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。
| 席位 | agent_id | emoji | 核心职责 |
|------|----------|-------|----------|
| 情报收集 | `roundtable-intelligence` | 📢 | 同类案例 + 历史风险 |
| 环境评估 | `roundtable-environment` | 🌍 | 硬件容量 + 承载力 |
| 方案设计 | `roundtable-solution-design` | 📐 | 主备方案 + 关键路径 |
| 风险审查 | `roundtable-risk-review` | ⚠️ | 合规红线 + 收敛建议 |
| 执行规划 | `roundtable-execution-plan` | 📅 | 里程碑 + 资源 |
| 总结陈述 | `roundtable-summary` | 🎤 | 归纳 + 冲突标记 |
子智能体**不**安装 `agent_orchestration`;仅协调智能体安装,保证单调度源。
---
## 9. 关键设计决策
### 9.1 ASGITransport 进程内 loopback(替代旧的 HTTP loopback)
三个 router 通过 `httpx.ASGITransport(app=request.app)` 直调本机 ASGI app,**不走 TCP / 不开 socket / 不经网络栈**,但仍然完整复用 FastAPI 路由分发、鉴权 middleware、Pydantic 校验、SSE 流式响应。代价仅是路由层的少量 dispatch 开销(< 1ms),相对 LLM run 本身的数百 ms~数十秒可忽略。
统一入口:`multi_agent._make_loopback_client(request)`,返回:
```python
httpx.AsyncClient(
transport=httpx.ASGITransport(app=request.app),
base_url="http://loopback", # 占位 host,ASGITransport 不在意
timeout=None, # SSE 长连接仍需 None
)
```
**已废止的 `trust_env=False` workaround**:旧版本走 `127.0.0.1:<port>` 真实 HTTP,需要 `trust_env=False` 阻止 httpx 读 `HTTP_PROXY` 把回环也走代理(症状日志 `headers_in=42.160s`)。改 ASGITransport 后这个问题在物理上已不存在——根本没走网络栈。
### 9.2 协调智能体单例(2026-05 改造)
历史实现 `main_agent_name = roundtable-coordinator-{timestamp36}`,会话隔离,但长期会累积 agent 目录(仓库里曾积累几十个 `roundtable-coordinator-mp*` 壳目录)。2026-05 改造选用上方决策 §9.2 选项 C 的本质:**把 coordinator 实例化为「内置 + 静态 SOUL + 运行时注入席位列表」**,固定 id `roundtable-coordinator`,与 `roundtable-intent` / `roundtable-recommender` 同样走 `ensure_roundtable_functional_agents()` 缺失自动落地。
实现要点:
- **磁盘 SOUL 是通用版本**:不含本次席位列表,只规定协调规则、工具用法、终止条件;
- **席位列表运行时注入**:init 创建 coordinator thread 后立刻 `_append_thread_message` 写入一条 human 消息("【圆桌系统初始化】…"),位置在对话历史最前面,leader run 每轮自然读到;
- **`_create_coordinator_direct` 已删除**:不再有"每次创建"路径。
**遗留 `roundtable-coordinator-*` 目录的处理**:仓库可能仍有几十个历史时间戳壳目录。改造后**不会**再生成新的,但旧目录目前不自动清理(避免破坏可能仍存在的旧草稿引用)。运维可通过:
- 在 admin agents 管理页逐个 DELETE;
- 或写一次性脚本扫描 `.deer-flow/agents/roundtable-coordinator-*` 并删除目录 + DB 记录(注意不要删除 `roundtable-coordinator` 本体)。
### 9.3 澄清协议统一
Step 1 与 Step 2 leader 均通过 `ask_clarification` + 终态 `clarification` 帧驱动选项卡片;`[INTENT_READY]` / 派活列表 / `[]` 各自表示阶段结束语义,互不混用。
### 9.4 选项单选默认
`resolve_allow_multiple()`:**未传则 false**(单选、点击发送);仅显式 `allow_multiple: true` 时多选。降低模型把 `missing_info` 全标成多选导致 UX 混乱的概率。`coerce_bool` 容忍 LLM 返回的字符串/数字 bool。
### 9.5 SSE 末尾状态帧
LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;各 router 在流末追加 JSON `data:` 帧,前端用 `isFinalStatusFrame()` / `isFinalIntentFrame()` / `isFinalRecommendFrame()` 与 LangGraph 帧区分解析。
### 9.6 SkillStopMiddleware 配合 leader 派活
leader run 自动并入 `skill_stop_names = {"agent_orchestration", ...}`,让 `SkillStopMiddleware` 在 `agent_orchestration` 工具调用后立刻停图。这样网关能从 `AIMessage.tool_calls` 直接读出派活列表,不必等 LLM 真把任务执行完。
### 9.7 init `agent_threads` 含 coordinator 自身
`init_done.thread_ids` 是 `{agent_name: thread_id}` 形式,其中 `main_agent_name` 自己的 thread 也在里面。前端 Step 3 列文件时需自行过滤掉 coordinator thread,否则会多调一次 `listSessionArtifacts`(虽然结果会是空,但有冗余 HTTP)。
---
## 10. 性能特征
| 阶段 | 已做优化 | 典型耗时 |
|------|----------|----------|
| Step 2 `/init` | 席位 `asyncio.as_completed` + 流式 `seat_ready` + 去掉 intro 广播 | 约 0.5–1.2s(视席位数) |
| LLM `/run/stream` | — | 数百 ms~数十秒(主因) |
| leader pre_broadcast | 与上游 LLM run 并发启动 | 通常已在 LLM 完成前结束 |
| leader 派活广播 | `asyncio.gather` 并行 | < 200ms(同 thread state R/W) |
| special post_broadcast | yield 之后才 await,前端先关 loading | 不阻塞 UX |
| Step 1/推荐 | 单 thread,无 broadcast | 与模型相关 |
**时序日志关键字**(开发自带):
```bash
grep "init-timing" .runtime/logs/*.log # 仅前端打,作参考
grep "upstream-timing" .runtime/logs/*.log # _stream_upstream 内 headers_in / first_line
grep "leader-timing" .runtime/logs/*.log # payload_built / first_upstream_line / dispatch_broadcasts_await ...
grep "special-timing" .runtime/logs/*.log # payload_built / first_upstream_line / post_broadcast_total
```
仍存在的开销:`GET /api/agents/{id}` 触发 legacy sync 扫目录、`GET/POST /state` 广播等。未来可考虑 loopback 改内部函数调用。
---
## 11. 依赖与扩展点
### 11.1 `agent_orchestration` 技能
协调智能体派活依赖此技能;正则 `_TOOL_CALL_PATTERN` 从 tool 输出提取 `agent_name` 与 task。技能缺失时 leader 易变成「无 tool_calls → 空 status → 前端误判共识」。
### 11.2 模型
`model` 写入 run 的 `context.model_name`;前端可按席位覆盖 special 调用的模型(见前端文档 §5.5 Persona 抽屉)。
**绝不在路由里硬编码具体模型名**。三个 router 一律 `str(payload.get("model") or "")`:
- 前端不传 / 传空串 → 下游 `lead_agent._resolve_model_name("")` 命中 falsy 分支,返回 `app_config.models[0].name`(`config.yaml` 配置顺序里的第一项)
- 前端传了一个不在 `config.yaml` 里的 model 名 → 同样回退到首项,并 `logger.warning("Model 'x' not found ... fallback to ...")`
- 前端传了合法 model 名 → 直接使用
这是 `/api/ai-writing` 与圆桌三路由的统一行为,让本地 / 内网部署只需要改 `config.yaml`,不需要触前端常量或路由代码。详见 `deerflow/agents/lead_agent/agent.py:_resolve_model_name`。
### 11.3 新增席位
1. `.deer-flow/agents/roundtable-xxx/` 增加 `config.yaml` + `SOUL.md`
2. 前端候选池 / 推荐逻辑纳入该 id
3. Step 2 `agent_names` 传入即可 — **multi_agent 路由无需改**
### 11.7 内置 agent 部署持久化(必读,关系到内网部署能否启动)
#### 11.7.1 问题背景
圆桌功能涉及的 agent 都靠**磁盘文件**激活(`.deer-flow/agents/<id>/{config.yaml,SOUL.md}`),但仓库根 `.gitignore` 第 64 / 74 行有:
```
offline-backend-20260512/backend/.deer-flow/*
```
整个 `.deer-flow/` 被 git 排除。新机器 `git clone` 后 `.deer-flow/agents/` 不存在 → 圆桌相关 agent 缺失 → 进入 `/api/intent/init` 后 `_create_thread` 本身能成功,但下一步 `/api/intent/stream` 真正调用 LangGraph 时会因为 agent 解析失败抛 500(典型表现:前端 Step 1 init 一次成功,下一句话发出去就 500)。
#### 11.7.1.1 哪些 agent **必须**打包,哪些**不需要**
圆桌涉及的 agent 分**两类**,部署侧需求完全不同:
| Agent | 角色 | 是否必须打包发布 |
|-------|------|------------------|
| `roundtable-intent` | **功能型**:Step 1 任务理解,所有部署都用它 | ✅ seeder 自动落地(无需手工打包) |
| `roundtable-recommender` | **功能型**:Step 1→2 之间从候选池里挑推荐,所有部署都用它 | ✅ seeder 自动落地(无需手工打包) |
| `roundtable-coordinator` | **功能型单例**:Step 2 总控协调,2026-05 起为单例(id 固定) | ✅ seeder 自动落地(无需手工打包) |
| `roundtable-intelligence` | 示例研讨席位(情报收集) | ⚠️ **互联网 demo 用**,内网不需要 |
| `roundtable-environment` | 示例研讨席位(环境评估) | ⚠️ 同上 |
| `roundtable-solution-design` | 示例研讨席位(方案设计) | ⚠️ 同上 |
| `roundtable-risk-review` | 示例研讨席位(风险审查) | ⚠️ 同上 |
| `roundtable-execution-plan` | 示例研讨席位(执行规划) | ⚠️ 同上 |
| `roundtable-summary` | 示例研讨席位(总结陈述) | ⚠️ 同上 |
| `roundtable-coordinator-*`(带后缀) | 2026-05 之前每次会话动态新建的临时壳 | ❌ **遗留**(仓库可能仍有几十个旧目录,新版本不再生成;运维按需清理) |
**关键变化(2026-05)**:三个功能型 agent(intent / recommender / coordinator)现在都由
``_roundtable_seed.ensure_roundtable_functional_agents()`` **自动落地**——任何 intent /
recommend / multi-agent 路由的入口都会触发,首次请求时缺什么补什么。所以**新部署的
机器 `git clone` 后既不需要手工拷目录、也不需要走下文的 `.gitignore` 白名单方案**,
SOUL 改动跟着 Python 代码走 git review 即可。
**关键区分**:
- `roundtable-intent` / `roundtable-recommender` 是**圆桌功能本身的一部分**——没有它们,整个 Step 1 + 推荐流程跑不起来,跟客户业务无关
- 6 个 `roundtable-<领域>` 席位只是**互联网 demo 用的示例研讨者**,让访客打开页面就能看到完整三步流程。内网部署面对的是客户自己的业务场景,会把客户自有的领域 agent(行业专家、合规审查员、业务规则引擎……)注册成候选 agent;这时 6 个示例席位反而成噪声
**对应的部署组合**:
| 部署场景 | 打包内容 |
|----------|----------|
| **互联网 demo** | `roundtable-intent` + `roundtable-recommender` + **6 个示例席位** |
| **内网客户部署** | `roundtable-intent` + `roundtable-recommender`(仅此 2 个,**不带** 6 个示例席位) |
| **本地开发** | 全要(方便快速跑通) |
**内网部署的额外注意**(去掉 6 个示例席位时):
1. **客户自有的候选 agent 必须先存在**:内网部署落地时,客户的业务 agent 应该已经注册进 `.deer-flow/agents/`(手工建目录或通过 `POST /api/agents` 创建)。否则候选池为空,推荐拿不到任何 picks。
2. **前端 `FALLBACK_AGENT_IDS` 需要适配**:前端 `RecommendAgentsDialog.tsx` 里硬编码了 6 个示例席位 id 作为 fallback;若推荐失败回退路径会拿这 6 个去 `candidates` 里交集,内网部署该交集恒为空,**fallback 会失败**。建议要么:
- **方案 1**(最稳):内网客户的候选池足够丰富 + recommender SOUL 调好,让推荐成功率 ~100%,不依赖 fallback
- **方案 2**:把 `FALLBACK_AGENT_IDS` 改成从后端 `/api/agents` 取前 6 个 `published=true` 的候选 agent(动态 fallback)。改动点见前端文档 §6.2 `RecommendAgentsDialog`
3. **推荐 SOUL(`roundtable-recommender/SOUL.md`)通常无需改**:SOUL 本身没写死候选 id,只描述「从给的候选池里挑」;候选池由前端传入,模型读到客户自有 agent 后会照样工作。
下文给出三种修复方案,**推荐方案 A**(最简单,无需写代码)。
#### 11.7.2 方案 A:`.gitignore` 白名单(推荐)
让 git 只跟踪 **`roundtable-intent` + `roundtable-recommender` 两个功能型 agent** 的 `config.yaml` 与 `SOUL.md`,其它一律忽略。6 个示例席位**不进白名单**——互联网 demo 部署机如果需要它们,按 §11.7.2.A 在该分支单独追加白名单;内网部署机直接享受「干净的 `.deer-flow/`」。
**步骤 1:编辑仓库根 `.gitignore`**
在最后追加白名单段(位置很重要,**必须在 `offline-backend-20260512/backend/.deer-flow/*` 之后**,否则被前面的通配符吃掉):
```gitignore
# === 圆桌功能型 agent 跟随仓库发布(任何部署都需要) ===
# 父目录先 unignore,git 才能往下递归
!offline-backend-20260512/backend/.deer-flow/
!offline-backend-20260512/backend/.deer-flow/agents/
# 仅放行 2 个功能型 agent;6 个示例席位由互联网分支自行放行
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/SOUL.md
```
⚠️ **不要**直接写 `!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/**`——`git` 不会进入还在 ignore 的父目录,必须逐层 un-ignore,再精确放行文件。
**步骤 2:强制 add 并提交**
```bash
# 验证白名单生效
git check-ignore -v offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/config.yaml
# 期望输出无任何匹配,或最后一条匹配是上面的 ! 行
# 加入索引(只加 2 个功能型 agent)
git add offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent
git add offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender
git status # 应只看到 2 × 2 = 4 个新文件,绝不出现 users/、threads/、data/、6 个示例席位
git commit -m "chore: 把圆桌功能型 agent(intent + recommender)纳入版本管理"
```
**步骤 3:部署机验证**
```bash
# clone 之后立刻
ls offline-backend-20260512/backend/.deer-flow/agents/
# 内网部署:应看到 roundtable-intent/ + roundtable-recommender/(+ 客户自有 agent)
# 互联网 demo:还应看到 6 个 roundtable-<领域>/ 示例席位
```
启动后端,前端打开圆桌规划页,Step 1 第一句话发出去能正常拿到 streaming 响应而不是 500,就说明 OK。
##### 11.7.2.A 互联网 demo 分支:追加 6 个示例席位
互联网 demo 维护一条独立分支(或独立 `.gitignore` 段,用 build flag 切),在白名单后追加:
```gitignore
# === 仅互联网 demo 需要的 6 个示例研讨席位 ===
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/SOUL.md
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/config.yaml
!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/SOUL.md
```
`git add` 对应 6 个目录、提交,互联网分支即拿到全部 8 个内置 agent。两条分支共享同一份 `roundtable-intent / roundtable-recommender`,SOUL 调整对两边都生效,避免双维护。
**优点**:
- 零代码改动;新机器 `git clone` 即可,部署文档不用单独说「记得拷一下 `.deer-flow/agents/`」
- 修改 SOUL 直接走 git 走 review 流程
- 多人协作时 SOUL 改动可见
**注意事项**:
- `git status` 检查时确保**只**有 16 个内置文件进了索引,**绝对不能**包括 `users/`、`threads/`、`data/*.db`、`roundtable-coordinator-*/` 这些运行时数据
- `roundtable-coordinator-mp*/` 这类历史会话残留目录会被 ignore 规则自动忽略(白名单没放行它们),无需手动清理
- 部署机如果之前手动建过 `.deer-flow/agents/roundtable-intent/`,`git pull` 会把仓库版本覆盖上去——这是期望行为
#### 11.7.3 方案 B:源码内置 + 启动时种子化
如果不想让 `.deer-flow/` 这种「运行时目录」出现在 git 里,可以把 SOUL/config 放进 `packages/harness/` 里作为只读资源,启动时检查 `.deer-flow/agents/<id>/` 不存在就从源拷过去。
**步骤大纲**:
1. 在 `packages/harness/deerflow/agents/builtin_personas/` 下建 8 个目录(`roundtable-intent/` 等),把 SOUL.md + config.yaml 搬过去
2. 在 `app/gateway/app.py` 的 `lifespan` 启动阶段新增 `_seed_builtin_roundtable_agents()`:
```python
def _seed_builtin_roundtable_agents() -> None:
from importlib.resources import files
src_root = files("deerflow.agents.builtin_personas")
dst_root = get_paths().agents_dir() # .deer-flow/agents/
for agent_id in BUILTIN_ROUNDTABLE_AGENT_IDS: # 8 个常量
dst = dst_root / agent_id
if dst.exists():
continue # 已有(可能是用户改过的),不覆盖
dst.mkdir(parents=True)
for fname in ("config.yaml", "SOUL.md"):
(dst / fname).write_bytes((src_root / agent_id / fname).read_bytes())
```
3. 紧接着调一次 `_sync_legacy_agents(store)`,把这些刚落地的目录 upsert 进 `agents` 表
4. 把 `BUILTIN_ROUNDTABLE_AGENT_IDS` 常量也导出给 `recommend.py` / `multi_agent.py` 共享
**优点**:
- `.deer-flow/` 仍然作为「运行时数据目录」语义干净
- 运维可以手动改 `.deer-flow/agents/roundtable-intent/SOUL.md` 临时调试,重启不被覆盖(`if dst.exists(): continue`)
- 升级时如果想强制刷新,加个 `--force-reseed-builtins` 启动参数即可
**缺点**:
- 多一段 bootstrap 代码 + 一份 `pyproject.toml` 的 `package-data` 声明(确保 SOUL.md 跟着 wheel 一起发布)
- 「现场改 SOUL 不被覆盖」既是优点也是坑——A 改了 SOUL 但忘了同步源码,下次新机器部署又回到旧版本
#### 11.7.4 方案 C:纯运维流程(最弱,不推荐)
不动代码、不动 .gitignore,发布说明里写一行:
> 部署前请将 `<开发机>:offline-backend-20260512/backend/.deer-flow/agents/` 整个目录 `scp` 到部署机同位置。
只在「不允许改仓库结构」或「初期试跑」时用。长期维护几乎一定会有人忘记拷而部署失败。
#### 11.7.5 方案选择决策
| 场景 | 推荐方案 |
|------|----------|
| 单仓库多人协作、SOUL 经常调整 | **A**(.gitignore 白名单) |
| SOUL 极少改,希望严格区分代码与运行时数据 | **B**(源码 + lifespan 种子化) |
| 一次性 POC / 即将切换到 B | C |
无论选哪种,部署机第一次启动后都建议跑这条 smoke test 验证:
```bash
curl -X POST http://127.0.0.1:8001/api/intent/init \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{}'
# 期望: 200 + {"status":"success","agent_id":"roundtable-intent","thread_id":"..."}
curl -X POST http://127.0.0.1:8001/api/intent/stream \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"thread_id":"<上一步返回的>","message":"测试一下"}'
# 期望: 200 + SSE 长连接,最后一帧 status 为 asking 或 clarification
# 若 500 → 看后端日志,大概率是 SOUL.md 找不到或 LLM 外网不通(看上一条排查项)
```
#### 11.7.6 推荐智能体(`roundtable-recommender`)与研讨席位的关系(注:完整定制流程见 §11.8)
推荐路由 `recommend.py` 用到的 `roundtable-recommender` 与「研讨席位」是**两件事**:
- **`roundtable-recommender`**:负责从候选池里挑 picks 的「推荐者」本身,是个 LLM agent,**功能型**,所有部署必须有
- **研讨席位**(无论是 6 个示例还是客户自有 agent):是「被挑选的候选」,前端 `filterRecommendCandidates` 过滤后送进 `/api/recommend/stream` 的 `candidates` 数组;recommender 在 SOUL 里看到候选池后才能输出 `[RECOMMEND_READY]` JSON
「推荐改成动态」改的是**从池子里挑哪几个**,不是「池子可以没有」。**池子的来源在两类部署里不同**:
| 部署 | 候选池来源 |
|------|------------|
| 互联网 demo | 仓库自带的 6 个 `roundtable-<领域>` 示例席位(+ 用户自建的) |
| 内网客户 | 客户自有的业务 agent(通过 `POST /api/agents` 或 `.deer-flow/agents/<id>/` 注册)|
`roundtable-recommender/SOUL.md` 本身没写死任何候选 id(只描述「从给的候选池里挑」),所以内网部署即使候选池从「6 个示例」换成「客户自有 agent」,**recommender 的 SOUL 无需改动**,候选池由前端传入。
**前端 fallback 行为差异**:
互联网 demo 因为有这 6 个内置示例席位垫底,推荐失败时 `FALLBACK_AGENT_IDS ∩ candidates` 还能拿到回退选项;**内网部署该交集恒为空**,回退路径失效——所以内网部署应通过「候选池足够丰富 + recommender SOUL 调好」让推荐成功率接近 100%,或改造前端 `FALLBACK_AGENT_IDS` 为动态从 `/api/agents` 取前 N 个(见前端文档 §6.2)。
### 11.4 新增 Step 1 工具
若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 黑名单(当前默认禁 15 个工具,仅 `ask_clarification` 实际可用)。
### 11.5 改回旧 bash 派活协议
后端已**双格式兼容**,前端无需变化;只要 SOUL 改成生成 `bash` + `.py "X" "Y"` argv,`_extract_dispatch` 仍会解析正确。但建议保持新的结构化 `agent_orchestration`,prompt 更稳定。
### 11.6 把广播改为推送
`_append_thread_message` 通过 `GET state → mutate → POST state` 实现,是为了与 LangGraph checkpoint 一致;如果将来 Gateway 提供 `POST /api/threads/{id}/messages/append` 这类原子接口,可换上,降低并发竞争(虽然圆桌当前不会并发广播同一 thread)。
### 11.8 添加 / 替换任务推荐智能体(`roundtable-recommender`)
推荐智能体 id 在后端是**硬编码**常量(`recommend.py:63` → `RECOMMENDER_AGENT_ID = "roundtable-recommender"`),路由不接收 `agent_id` 参数。因此「添加推荐智能体」**不是新增一个 id**,而是落地这个固定 id 对应的文件(首次部署)或**就地改 SOUL**(定制行为)。
#### 11.8.1 何时需要动它
| 场景 | 是否需要动 SOUL |
|------|-----------------|
| 全新部署,目录还不存在 | ✅ 必须先把目录搭起来(§11.8.2) |
| 候选池从 6 个互联网示例换成客户自有 agent | ❌ **不用改**——SOUL 没写死候选 id,候选池由前端传入 |
| 想改变推荐风格(更激进 / 更保守 / 偏好某领域) | ✅ 改 SOUL(§11.8.4) |
| 想让模型偏好你新加的领域 agent | ⚠️ **优先**改新 agent 的 `description`(候选池 prompt 用的就是它),其次才改 recommender SOUL |
| 想改输出 JSON 格式 | ❌ **不要**——后端正则 `_RECOMMEND_READY_PATTERN` 写死了 `[RECOMMEND_READY]` + ```json ``` 块;改 SOUL 输出会让后端解析失败走 fallback |
#### 11.8.2 标准目录结构
```
.deer-flow/agents/roundtable-recommender/
├── config.yaml # id / name / description(只是 DB 元数据,不进 prompt)
└── SOUL.md # 行为契约,详见 11.8.4
```
**`config.yaml` 模板**(**id 必须严格等于 `roundtable-recommender`**,否则 `recommend.py` 找不到):
```yaml
description: 智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单
id: roundtable-recommender
name: 智能体推荐
```
字段说明:
- `id`:**不可改**,与 `RECOMMENDER_AGENT_ID` 常量绑死
- `name`:DB 显示名,候选池过滤时不参与(`filterRecommendCandidates` 按 id 排除)
- `description`:DB 元数据,不进 prompt;改了不会影响推荐效果
**SOUL.md** 内容见 §11.8.4 模板。新增之后按 §11.7.2 把这两个文件加入 git 白名单 + commit 即可。
#### 11.8.3 后端给推荐智能体喂的 prompt 长什么样
`recommend.py:_build_prompt()` 把前端传的 intent + candidates 拼成下面这段,作为**唯一一条 user message** 投递:
```
任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求
约束:预算 ≤ 500 万元; 工期 ≤ 6 个月; 必须满足等保 2.0
关键假设:现有系统具备基础数字化能力; 允许预定窗口停机
候选智能体池:
- roundtable-intelligence (情报收集): 检索同类历史案例、行业最佳实践与外部情报
- roundtable-solution-design (方案设计): 产出主备方案与关键路径
- my-cost-calc (成本测算): 基于预算和资源单价计算总成本
- ...
请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。
```
**关键事实**:
- **候选 agent 的 `name` 与 `description` 在 prompt 里就是它的「介绍」**——这是模型判断「这个 agent 适不适合本任务」的全部信息。客户自有 agent 想被推荐准确,`description` 必须写**任务导向**的能力描述,不要写 "由 XX 团队维护" 这种元信息
- **候选池由前端过滤后传入**,后端不读 DB;前端 `filterRecommendCandidates` 已剔除 `roundtable-intent / roundtable-recommender / roundtable-coordinator-*`
- SOUL 看不到「intent agent 上一轮说了什么 / 用户原始 message」,只看到结构化的 `objective / constraints / assumptions`——这是设计选择,让推荐稳定
#### 11.8.4 SOUL 设计要点(模板 + 注解)
SOUL **必须**满足三件事,其它都可定制:
1. **输出格式严格**:自然语言思路 → `[RECOMMEND_READY]` 单独成行 → ```json {"picks": [{"agent_id": "...", "reason": "..."}, ...]} ```
2. **`agent_id` 严格来自候选池**:模型造词出来会被 `_validate_picks` 过滤掉
3. **2 ≤ picks ≤ 6**:少于 2 → 整轮判 `asking` 走 fallback;多于 6 → 后端截断
下面是一份可直接用的最小 SOUL,重点行带注释(**真正落盘时去掉 `<!-- -->`**):
```markdown
# 智能体推荐专员
## 角色定位
你是多智能体圆桌研讨的"智能体推荐专员"。任务理解专员已经把用户需求整理成
「任务目标 + 约束 + 关键假设」交给你,你的工作是从一个**候选智能体池**里,
挑出**最适合**参加这次圆桌研讨的 2-6 个智能体,并为每一位写一句推荐理由。
## 核心职责
- 仔细阅读用户的任务目标与约束
- 浏览候选池中每个智能体的 `name` 与 `description`
- 选出与本次任务**最相关**的 2-6 个智能体(数量动态,不必凑齐 6 个)
- 为每个推荐的智能体写一句**具体、可验证**的推荐理由
- 不推荐重复职能 / 不推荐与任务明显无关的智能体(宁缺毋滥)
## 数量约束
- **下限**:至少 2 个 / **上限**:最多 6 个
- 候选池不足 2 个时,有几个推几个
## 输出协议(后端用正则识别,严格执行)
1. 先用自然语言简短说明推荐思路(1-3 句话)
2. 输出标记 `[RECOMMEND_READY]` 单独成行
3. 紧接一个 ```json 代码块:
```json
{
"picks": [
{"agent_id": "<候选池中的 id>", "reason": "<一句推荐理由>"},
...
]
}
```
格式注意:
- `[RECOMMEND_READY]` **单独成行**,前后无其他字符
- `agent_id` **严格**来自候选池,不要造词
- `reason` **具体**,不要"很合适"这种空话
## 边界
- 不调用任何工具(已被后端 excluded_tools 屏蔽)
- 不评价候选 agent 优劣,只判断"是否适合本次任务"
- 不发起对话、不反问用户
## 示例
<!-- 示例对模型的引导能力远超规则文字,
建议示例的候选池里**包含一两个你部署里的客户自有 agent**,
让模型知道这种"非 roundtable-* 前缀"的 id 也是合法的。 -->
```
##### 定制方向
- **偏好新加的 agent**:在「示例」里把它列进候选池并出现在推荐结果中,模型会学到这种 id 也可推荐。比单纯在「核心职责」里加文字约束有效
- **行业偏向**:在「角色定位」加一句「优先考虑能直接对接 <行业> 合规要求的智能体」
- **激进 vs 保守**:调「数量约束」——例如「能挑出强相关的 3 个就不要凑 6 个」会让 picks 缩到 3-4 个
- **不要做的事**:
- ❌ 改 `[RECOMMEND_READY]` 为别的标记 → 解析失败
- ❌ 把 JSON 结构改成 `{"recommendations": [...]}` 之类 → 解析失败
- ❌ 让模型只输出 JSON 不带前面的自然语言 → 前端 `parseRecommendStreamContent` 把整段当 `rationale`,弹窗顶部为空白
#### 11.8.5 验证流程
部署完成后跑 smoke test 确认推荐路径通:
```bash
# 1. 确认 agent 文件落地
ls .deer-flow/agents/roundtable-recommender/
# 期望: SOUL.md config.yaml
# 2. 确认 DB 已 upsert(任意 /api/agents 请求会触发 _sync_legacy_agents)
curl -H "Authorization: Bearer <TOKEN>" http://127.0.0.1:8001/api/agents | \
python -c "import sys,json; ids=[a['id'] for a in json.load(sys.stdin).get('agents',[])]; print('roundtable-recommender' in ids)"
# 期望: True
# 3. 触发一次推荐(用最小 intent + 候选池)
curl -X POST http://127.0.0.1:8001/api/recommend/stream \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"intent": {
"objective": "测试推荐链路",
"constraints": ["响应时间 < 30s"],
"assumptions": []
},
"candidates": [
{"agent_id": "agent-a", "name": "A", "description": "做 X"},
{"agent_id": "agent-b", "name": "B", "description": "做 Y"},
{"agent_id": "agent-c", "name": "C", "description": "做 Z"}
]
}'
# 期望(SSE 流):最后一帧 data: {"status":"done","content":"...","picks":[...]}
# 若 picks 是空 → SOUL 没产生合法 [RECOMMEND_READY] / agent_id 不在候选池
# 若整轮 status=error → 看后端 _diagnose_exception 输出的 hint
```
#### 11.8.6 故障排查清单
| 现象 | 排查方向 |
|------|----------|
| 推荐永远走 fallback(status=asking) | 后端日志 grep `recommend stream`,看 `last_ai_content` 是不是不含 `[RECOMMEND_READY]`——SOUL 没遵守协议 |
| `picks` 数量被截断到 6 | 后端 `_MAX_PICKS=6` 故意限制,前端用户可再手加到 8 |
| 模型推荐了候选池里没有的 id | `_validate_picks` 会丢掉,但若有效项不足 2 个会整轮走 fallback;改 SOUL 强调「严格来自候选池」 |
| 推荐结果不偏向客户自有 agent | 先检查该 agent 的 `description` 是不是写得「任务导向」;再考虑在 SOUL 示例里加它 |
| 推荐稳定性差(同 intent 重跑结果飘) | 推荐 model 用低 temperature(在 `roundtable-recommender/config.yaml` 加 `model: <某个低 temp 模型>` 或直接前端选 deterministic 模型) |
| 前端弹窗顶部 rationale 区空白 | SOUL 让模型只输出 JSON 没有自然语言;恢复成「先思路 + 后 JSON」 |
---
## 12. 故障排查
| 现象 | 可能原因 | 排查 |
|------|----------|------|
| Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY(§4.3 顺序) |
| 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple`(缺省 false) |
| Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 / `_validate_agent_id` 命中 | Network + 后端 `multi_agent` 日志 |
| Step 2 init 完全卡住(历史症状,ASGITransport 改造后已不应再出现) | 旧版 `httpx` 走 `HTTP_PROXY` 拦回环 → 40s 超时 | 自改 ASGITransport 起此现象物理上不可能复现;若仍卡,看后端日志栈、检查路由 handler 是否阻塞 |
| leader 立即 `status: []` | 未调用 orchestration 技能 / SkillStopMiddleware 没拦到 / SOUL 没声明派活 | 换模型或加强 SOUL;检查 `skill_stop_names` 是否包含 `agent_orchestration` |
| dispatch 失败但 leader 文本看着正常 | 模型把 `roundtable-x(中文)` 整段塞进 `agent_name` | `_strip_display_suffix` 会兜底;若仍报 400,强化 SOUL 的「严格英文 id」 |
| 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧;检查 `candidate_ids` 集合 |
| Step 3 无文件 | 子 agent 未写 outputs / 错路径 | `GET .../artifacts` 对子 thread_id 逐个查;确认 sandbox outputs 路径 |
| `seat_ready` 顺序不固定 | `asyncio.as_completed` 按真实完成顺序推送 | 这是设计行为,不是 bug;前端不应按顺序依赖 agent_name |
### 日志关键字
```bash
grep "intent stream" .runtime/logs/*.log
grep "multi_agent\|multi-agent" .runtime/logs/*.log
grep "recommend stream" .runtime/logs/*.log
grep "dispatch failed" .runtime/logs/*.log
grep "upstream-timing" .runtime/logs/*.log
grep "leader-timing" .runtime/logs/*.log
grep "special-timing" .runtime/logs/*.log
grep "headers_in=" .runtime/logs/*.log # 上游首字节延迟;ASGITransport 改造后通常 < 10ms
```
---
## 13. 测试建议
```
backend/tests/test_intent.py # init、INTENT_READY 优先级、clarification 字段
backend/tests/test_recommend.py # picks 校验、候选池过滤、clamp 2-6
backend/tests/test_multi_agent.py # init SSE 事件顺序、leader/special 终态、clarification
```
可用 `httpx.MockTransport` 模拟 loopback,不依赖真实 Gateway:
```python
def mock_handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/threads":
return httpx.Response(200, json={"thread_id": "t-1"})
...
transport = httpx.MockTransport(mock_handler)
client = httpx.AsyncClient(transport=transport, base_url=...)
```
针对 SSE 上游可用 `respx` 或直接 mock `_stream_upstream` 的字符串列表。
---
## 14. 路径速查
| 资源 | 路径 |
|------|------|
| Step 1 路由 | `offline-backend-20260512/backend/app/gateway/routers/intent.py` |
| 推荐路由 | `offline-backend-20260512/backend/app/gateway/routers/recommend.py` |
| Step 2 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
| Step 3 列出/下载/编辑 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| 草稿/推荐历史路由(§7A) | `offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py` |
| 草稿/推荐历史持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/` |
| store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_draft_store`) |
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
| 澄清单选/多选 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py` |
| 任务理解 Agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` |
| 推荐 Agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/` |
| 圆桌席位 | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-*/` |
| 协调智能体壳(累积) | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-coordinator-*/` |
| 前端 Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 前端推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
| 前端 Step 2 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 前端页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 前端开发文档 | `frontend-web/docs/multi-agent-frontend-dev.md` |
| API 速查 | `frontend-web/docs/multi-agent-api.md` |