# 多智能体圆桌 · Step 1 · 任务理解智能体接口与实现 > Step 1(`/page/roundtable/planning` 的"任务意图与澄清"页)对应 `RoundtablePlanningPage.tsx` 里 `currentStep === 1` 的渲染分支。当前它是纯前端 mock —— 用户发消息后 `setTimeout(1500)` 拼一段固定话术回。本文档定义一个**内置任务理解智能体 + 2 个轻量接口**,让 step 1 跑起来真正的对话,为 step 2 输入更高质量的"任务目标 + 约束"。 > > 整体风格直接参照 `multi-agent-api.md` / `multi-agent-backend-dev.md`。 --- ## 1. 目标与边界 ### ✅ 要做的事 - 内置一个智能体:**任务理解智能体**(`roundtable-intent`),跟 6 个 roundtable 席位同等地位 - 提供 2 个接口:`/api/intent/init` 和 `/api/intent/stream` - 复用 step 2 的 SSE 协议风格(前端只需 1 个新 helper) - 让用户跟智能体多轮澄清后,把最终"目标 + 约束"传给 step 2 的 `runOrchestration` ### ❌ 不做的事 - 不重新设计澄清协议(直接用 `ask_clarification` 工具 + ClarificationMiddleware 现成机制,跟 step 2 leader 一样) - 不做"自动跳过"按钮的真实业务逻辑(保留前端按钮形态即可) - 不做时间线(timelineEntries)的后端持久化(前端临时状态够用) - 不做"评分/置信度"等假指标 - 不做单独的"任务理解专员模型选择"——直接用全局 `DEFAULT_MODEL` --- ## 2. 数据流概览 ``` [Step 1 入口] ↓ 前端:POST /api/intent/init ← {agent_id: "roundtable-intent", thread_id: "..."} 用户输入第一句话:"帮我规划一个生产系统安全改造方案" ↓ 前端:POST /api/intent/stream (SSE) ← (流式 AI 文本) "好的,预算大致是多少?" ← {status: "asking", content: "..."} ← 智能体反问中,需要用户继续 用户回:"500 万左右" ↓ 前端再次 POST /api/intent/stream ← (流式) "明白,还有什么时间约束吗?" ← {status: "asking", ...} ...对话进行几轮 ... 用户:可以了 ↓ 前端再 POST /api/intent/stream ← (流式) "好,我理解的任务是: <汇总>" ← {status: "done", content: "<最终汇总文本>", summary: {...}} ← 信息充分,可进入 step 2 [Step 1 完成 → Step 2 init] 前端把 summary.objective + summary.constraints 拼成 step 2 的 buildInitialIntent() ``` --- ## 3. 接口设计 跟 step 2 完全同构。`/api/intent/init` 简化版(只建 1 个 thread + 1 个 agent,没有"协调"概念);`/api/intent/stream` 跟 `/api/multi-agent/run/stream` 的 special 分支几乎一样。 ### 3.1 `POST /api/intent/init` **请求体** ```json { "model": "deepseek-chat" // 可选,默认全局 DEFAULT_MODEL } ``` **响应 200** ```json { "status": "success", "agent_id": "roundtable-intent", "thread_id": "" } ``` **行为**: 1. 鉴权(透传 `Authorization`) 2. 调上游 `POST /api/threads` 建 1 个 thread 3. 直接返回 `agent_id = "roundtable-intent"`(**这是内置 agent,不需要每次新建**) **与 step 2 `/init` 的区别**: - 没有"协调智能体"概念,只有 1 个 agent - 没有 6 个 sub 席位,只有 1 个 thread - 不需要传 `agent_names` 数组 - `agent_id` 是固定字符串 `"roundtable-intent"`,不带 timestamp 后缀(智能体本身是内置、长期、复用的) ### 3.2 `POST /api/intent/stream` **请求体** ```json { "thread_id": "", "message": "用户这一轮要说的话", "model": "deepseek-chat" // 可选 } ``` **响应**:`text/event-stream`,SSE 流式。 **SSE 帧组成**: 1. 透传上游 LangGraph 的所有帧(`event: metadata` / `event: messages` / `event: values`)—— 前端用现有的 `extractAiTextDelta` 解析 AI 文本增量 2. 流末追加一个**状态帧**(跟 step 2 同样模式): ```json { "status": "asking" | "done" | "error", "content": "<本轮 AI 文本>", "summary": { "objective": "...", // 仅 status=done 时有: 任务目标 "constraints": ["...", ...], // 仅 status=done 时有: 约束清单 "assumptions": ["...", ...] // 仅 status=done 时有: 关键假设 } } ``` **`status` 取值**: - `"asking"` —— 智能体还在追问,前端应该把焦点放回输入框 - `"done"` —— 智能体宣布"信息已充分",前端就可以亮起"进入下一步"按钮,并拿 `summary` 拼 step 2 的初始 intent - `"error"` —— 上游异常,`content` 里是错误信息 **判定 done/asking 的简单策略**(后端实现): - 智能体在 SOUL 里被告知:信息已充分时**必须**调 `present_intent_summary` 工具(或直接在 AIMessage 里用一个固定标记 `[INTENT_READY]`),后端识别后发 `done` 帧 - 否则一律是 `asking` > 上面"summary 工具"是新增的内置工具吗?不一定。最省事的做法是**让模型用 JSON 块输出**,后端用正则提取: > > ``` > [INTENT_READY] > ```json > {"objective": "...", "constraints": ["..."], "assumptions": ["..."]} > ``` > ``` > > 后端流末扫一下最末 AIMessage 里有没有这个标记,有就 parse JSON 出来填到 summary,发 `done`;没有就发 `asking`。 --- ## 4. 内置智能体配置 **位置**:`offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` ### 4.1 `config.yaml` ```yaml id: roundtable-intent name: 任务理解智能体 description: 帮助用户明确任务目标、约束、关键假设,为后续多智能体圆桌研讨准备结构化输入。 ``` ### 4.2 `SOUL.md` ```markdown # 任务理解智能体 ## 角色定位 你是多智能体圆桌研讨的"任务理解专员"。你的工作是在用户和 6 个圆桌席位之间架一座桥: 通过 2-5 轮简短追问,把用户模糊的需求转化成结构化的「任务目标 + 约束 + 关键假设」。 ## 核心职责 - 用清晰、简短的问题引导用户补全关键信息(优先级: 目标 > 硬约束 > 软偏好) - 不要追问已经清楚的事 - 不要做方案,不要给具体技术建议——那是后面 6 个席位的工作 - 信息足够后,立即停止追问,产出结构化汇总 ## 信息充分性判定 当满足以下条件时,认为信息已充分: 1. 明确的任务目标(至少能写出一句 "用户希望实现 X") 2. 至少 1 条硬约束(预算/时间/合规/范围之一) 3. 没有明显的歧义点 ## 信息汇总产出 信息充分时,你必须在回复末尾输出**精确的标记 + JSON 块**(给后端识别用): ``` [INTENT_READY] ```json { "objective": "用一句话写出用户最终要做的事", "constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...], "assumptions": ["假设 1", "假设 2", ...] } ``` ``` 输出前可以先用自然语言总结一遍给用户看(让用户能 review 内容),再附上述标记 + JSON。 ## 输出风格 - 一次回复不超过 3 句话 - 提问要具体,不要"还有什么补充吗"这种空问 - 用 emoji 简短点缀(❓ 表示问题, ✅ 表示明确, 📋 表示汇总) ## 边界 - 不调用 agent_orchestration(这是 step 2 的协调智能体的事) - 不写文件、不做计算、不联网 - 用户消息歧义大时,优先调 ask_clarification 工具引导 ``` ### 4.3 注册方式 跟现有 6 个 `roundtable-*` 一样,通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次任意 `/api/agents` 请求时自动 upsert 进 `agents` 表,`user_id IS NULL` 标记为内置。**零代码改动**。 --- ## 5. 后端实现要点 ### 5.1 新建 router 文件 **位置**:`offline-backend-20260512/backend/app/gateway/routers/intent.py` ```python """任务理解智能体接口 - Step 1 的极简包装。 跟 multi_agent.py 同构但简化: - 没有 broadcast(单 thread,无需广播) - 没有派活逻辑 - 没有 leader/special 分支,只有一种调用方式 - 流末状态帧解析 [INTENT_READY] 标记决定 status=done/asking """ from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import StreamingResponse import httpx, json, re, asyncio, logging logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/intent", tags=["intent"]) # 复用 multi_agent.py 里已有的工具函数 from app.gateway.routers.multi_agent import ( _loopback_base, _auth_headers, _create_thread, _stream_upstream, _parse_last_messages, _flatten_content, _run_payload, ) INTENT_AGENT_ID = "roundtable-intent" _INTENT_READY_PATTERN = re.compile( r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```", re.DOTALL, ) @router.post("/init") async def init_intent(request: Request, payload: dict = Body(...)) -> dict: base = _loopback_base(request) headers = _auth_headers(request) async with httpx.AsyncClient(timeout=None) as client: thread_id = await _create_thread(client, base, headers) return { "status": "success", "agent_id": INTENT_AGENT_ID, "thread_id": thread_id, } @router.post("/stream") async def stream_intent(request: Request, payload: dict = Body(...)) -> StreamingResponse: thread_id = str(payload.get("thread_id") or "").strip() message = str(payload.get("message") or "").strip() model_name = str(payload.get("model") or "deepseek-chat") if not thread_id or not message: raise HTTPException(status_code=400, detail="thread_id and message required") base = _loopback_base(request) headers = _auth_headers(request) async def gen(): async with httpx.AsyncClient(timeout=None) as client: try: body = _run_payload( agent_name=INTENT_AGENT_ID, model_name=model_name, thread_id=thread_id, new_message=message, excluded_tools=["web_search", "present_files", "view_image", "agent_orchestration"], skill_stop_names=[], ) captured = [] async for line, done in _stream_upstream(client, base, headers, thread_id, body): if line is not None: yield (line if line.endswith("\n") else line + "\n") elif done is not None: captured = done messages = _parse_last_messages(captured) last_ai = {} for m in reversed(messages): if isinstance(m, dict) and (m.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"): last_ai = m break content = _flatten_content(last_ai.get("content", "")) # 检测 [INTENT_READY] 标记 match = _INTENT_READY_PATTERN.search(content) if match: try: summary = json.loads(match.group(1)) except json.JSONDecodeError: summary = None frame = {"status": "done", "content": content, "summary": summary} else: frame = {"status": "asking", "content": content} yield f"data: {json.dumps(frame, ensure_ascii=False)}\n\n" except HTTPException as exc: yield f"data: {json.dumps({'status':'error','content':f'[{exc.status_code}] {exc.detail}'}, ensure_ascii=False)}\n\n" except Exception as exc: logger.exception("intent stream failure") yield f"data: {json.dumps({'status':'error','content':f'unexpected: {exc!r}'}, ensure_ascii=False)}\n\n" return StreamingResponse( gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"}, ) ``` ### 5.2 在 `app/gateway/app.py` 注册 ```python from app.gateway.routers import intent app.include_router(intent.router) ``` --- ## 6. 前端实现要点 ### 6.1 新增 API helper **位置**:`frontend-web/src/roundtable-planning/api/intent.ts` ```ts import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; export interface IntentInitResponse { status: "success"; agent_id: string; thread_id: string; } export async function initIntent(model?: string): Promise { const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model }), }); if (!res.ok) throw new Error(`init intent failed (${res.status}): ${await res.text()}`); return res.json(); } export interface IntentSummary { objective: string; constraints: string[]; assumptions?: string[]; } export interface StreamIntentRequest { threadId: string; message: string; model?: string; signal?: AbortSignal; onTextDelta?: (text: string, id: string) => void; } export interface StreamIntentResult { status: "asking" | "done" | "error"; content: string; summary: IntentSummary | null; } export async function streamIntent(req: StreamIntentRequest): Promise { // 实现:reader 循环跟 streamMultiAgent 几乎一样,只是状态帧解析改成 // {status, content, summary?} 格式。 // ...(参考 multi-agent.ts 的 streamMultiAgent 改写,简化 80%) } ``` ### 6.2 改造 `RoundtablePlanningPage.tsx` 的 step1 渲染 替换 `handleSendMessage`: ```ts const [intentThreadId, setIntentThreadId] = useState(null); const [intentReady, setIntentReady] = useState(null); useEffect(() => { if (currentStep === 1 && !intentThreadId) { initIntent(DEFAULT_MODEL).then(r => setIntentThreadId(r.thread_id)); } }, [currentStep]); const handleSendMessage = async () => { if (!inputText.trim() || !intentThreadId) return; const userText = inputText.trim(); // (push user msg to interactiveMessages 略) setInputText(""); // 占位 agent 气泡 const agentMsgId = pushAgentPlaceholder("任务理解智能体"); const res = await streamIntent({ threadId: intentThreadId, message: userText, model: DEFAULT_MODEL, onTextDelta: (delta) => appendToAgentMsg(agentMsgId, delta), }); finalizeAgentMsg(agentMsgId, res.content); if (res.status === "done" && res.summary) { setIntentReady(res.summary); triggerToast("✅ 任务理解完成,可以进入圆桌研讨"); } }; ``` ### 6.3 "进入 step 2"按钮的联动 - 当前页面已有"完成澄清/进入下一步"按钮(应该是路由到 `currentStep === 2`) - 改造:只有 `intentReady !== null` 时该按钮才 enabled - 点击后:把 `intentReady.objective + intentReady.constraints` 写入 `buildInitialIntent()` 的源(替换现在按 `interactiveMessages` 找最后一条 user msg + budgetLimit 拼接的逻辑) ```ts const buildInitialIntent = useCallback(() => { if (intentReady) { return `${intentReady.objective}\n\n约束条件:\n${intentReady.constraints.map(c => `- ${c}`).join("\n")}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`; } // fallback: 旧逻辑保留兜底 const lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user"); const userIntent = lastUserMessage?.content ?? "请围绕给定任务展开圆桌研讨"; const constraints = `预算硬约束:${budgetLimit} 万元`; return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`; }, [interactiveMessages, budgetLimit, intentReady]); ``` --- ## 7. 实施清单 | 优先级 | 项 | 文件 | |---|---|---| | P0 | 内置 agent 目录 | `backend/.deer-flow/agents/roundtable-intent/{config.yaml,SOUL.md}` | | P0 | 新 router | `backend/app/gateway/routers/intent.py` | | P0 | 注册 router | `backend/app/gateway/app.py`(一行 `include_router`) | | P0 | 前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` | | P0 | 前端页面改造 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(handleSendMessage + buildInitialIntent + 入口 useEffect) | | P1 | step 1 进入下一步按钮 disabled 状态联动 `intentReady` | 同上 | | P2 | 单测 | `backend/tests/test_intent.py`(init 返回 agent_id 固定值, stream 识别 INTENT_READY 标记) | | P3 | 失败兜底(INTENT_READY 标记被模型遗漏时的"用户手动确认进入下一步"按钮) | 前端 | --- ## 8. 与 step 2 的衔接 ``` step 1 输出: intentReady.objective + intentReady.constraints ↓ step 2 入口: buildInitialIntent() 拼成 leader 的 new_message ↓ step 2 leader 不再需要主动反问澄清(因为 step1 已经清完了), 除非模型遇到完全意料之外的歧义 ↓ 研讨更聚焦,减少 step2 阶段的 clarification 轮次 ``` 副产物:协调智能体的 SOUL 可以稍微调整(参考 `multi_agent.py:soul = "#核心定位..."` 那段),加一句"用户已经经过任务理解智能体澄清,task 描述里包含目标+约束,请直接开始派活而不是再反问",进一步减少 step2 的反问。 --- ## 9. 路径速查 | 资源 | 位置 | |---|---| | Step 1 渲染 | `RoundtablePlanningPage.tsx` 内 `currentStep === 1` 分支(约 line 1418+) | | 新增前端 helper | `frontend-web/src/roundtable-planning/api/intent.ts` | | 新增后端 router | `offline-backend-20260512/backend/app/gateway/routers/intent.py` | | 内置 agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` | | 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` | | 参照实现 | `multi_agent.py`(接口模式、SSE 包装、状态帧) | --- ## 10. 验收清单 - [ ] 进入 step 1 自动调 `/api/intent/init`,拿到 thread_id - [ ] 用户每次发消息后,任务理解智能体真实流式输出(不是 setTimeout) - [ ] 智能体能在 2-5 轮对话后自动停止追问,发 `status: done` 帧并附 summary - [ ] step 2 的初始 intent 用 step1 的 summary 拼接,不再依赖 `budgetLimit` 这种页面状态 - [ ] 离开/重新进入 step 1 时,thread_id 状态干净(不会带着上次的对话) - [ ] 任何接口失败都不让 step 1 卡死(容错的"用旧 mock 兜底"或者明确错误提示)