# 多智能体圆桌研讨 · 后端开发文档 本文档面向需要维护或扩展「多智能体圆桌研讨」功能后端的开发者。前端 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:`,遇到开发机 `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 `。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-/ # 协调智能体壳,每次会话新建,长期累积 ``` **内置 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": "", "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`): ``` 任务目标: 约束:; ; ... 关键假设:; ... 候选智能体池: - (): - ... 请按 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(情报收集): - agent_name: roundtable-environment(环境评估): ... ``` 中文显示名通过 `_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 追加 "总控智能体收到用户消息:" 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 | `总控智能体收到用户消息:` | `[leader]` | | leader 派活 | `总控智能体给 X 派活,内容为:` | `[leader, sub_name]` | | special 后广播 | `子智能体 X 完成 Y 工作,交付内容为:` | `[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": , "model_name": "deepseek-chat", "mode": "pro", "reasoning_effort": "medium", "thinking_enabled": True, "is_plan_mode": False, "subagent_enabled": False, "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": , } ``` 字段含义: - `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] 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:` 真实 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//{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//` 不存在就从源拷过去。 **步骤大纲**: 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 " \ -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 " \ -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//` 注册)| `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**:在「示例」里把它列进候选池并出现在推荐结果中,模型会学到这种 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 " 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 " \ -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` |