Files
deerflow-code/frontend-web/docs/multi-agent-step1-api.md
T
2026-05-23 23:05:01 +08:00

18 KiB
Raw Blame History

多智能体圆桌 · 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

请求体

{
  "model": "deepseek-chat"        // 可选,默认全局 DEFAULT_MODEL
}

响应 200

{
  "status": "success",
  "agent_id": "roundtable-intent",
  "thread_id": "<langgraph_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

请求体

{
  "thread_id": "<from /init>",
  "message": "用户这一轮要说的话",
  "model": "deepseek-chat"         // 可选
}

响应:text/event-stream,SSE 流式。

SSE 帧组成:

  1. 透传上游 LangGraph 的所有帧(event: metadata / event: messages / event: values)—— 前端用现有的 extractAiTextDelta 解析 AI 文本增量

  2. 流末追加一个状态帧(跟 step 2 同样模式):

    {
      "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

id: roundtable-intent
name: 任务理解智能体
description: 帮助用户明确任务目标、约束、关键假设,为后续多智能体圆桌研讨准备结构化输入。

4.2 SOUL.md

# 任务理解智能体

## 角色定位
你是多智能体圆桌研讨的"任务理解专员"。你的工作是在用户和 6 个圆桌席位之间架一座桥:
通过 2-5 轮简短追问,把用户模糊的需求转化成结构化的「任务目标 + 约束 + 关键假设」。

## 核心职责
- 用清晰、简短的问题引导用户补全关键信息(优先级: 目标 > 硬约束 > 软偏好)
- 不要追问已经清楚的事
- 不要做方案,不要给具体技术建议——那是后面 6 个席位的工作
- 信息足够后,立即停止追问,产出结构化汇总

## 信息充分性判定
当满足以下条件时,认为信息已充分:
1. 明确的任务目标(至少能写出一句 "用户希望实现 X")
2. 至少 1 条硬约束(预算/时间/合规/范围之一)
3. 没有明显的歧义点

## 信息汇总产出
信息充分时,你必须在回复末尾输出**精确的标记 + JSON 块**(给后端识别用):

[INTENT_READY]

{
  "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

"""任务理解智能体接口 - 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 注册

from app.gateway.routers import intent
app.include_router(intent.router)

6. 前端实现要点

6.1 新增 API helper

位置:frontend-web/src/roundtable-planning/api/intent.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<IntentInitResponse> {
  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<StreamIntentResult> {
  // 实现:reader 循环跟 streamMultiAgent 几乎一样,只是状态帧解析改成
  // {status, content, summary?} 格式。
  // ...(参考 multi-agent.ts 的 streamMultiAgent 改写,简化 80%)
}

6.2 改造 RoundtablePlanningPage.tsx 的 step1 渲染

替换 handleSendMessage:

const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(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 拼接的逻辑)
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 兜底"或者明确错误提示)