Browse Source

init: Multi-Agent 演示系统

杨一林 1 month ago
commit
8bf55d81f8

+ 243 - 0
README.md

@@ -0,0 +1,243 @@
+# Multi-Agent 演示系统 — 使用文档
+
+## 项目概览
+
+基于 **FastAPI + SSE + 真实 LLM 调用** 的多 Agent 协作演示系统,展示三种经典多 Agent 架构模式:
+
+| 模式 | 流程 | Agent 数量 |
+|------|------|-----------|
+| **Supervisor(主管模式)** | Supervisor 拆任务 → Researcher 搜索 → Coder 分析 → Supervisor 整合 | 3 |
+| **Pipeline(流水线模式)** | 搜索 Agent → 分析 Agent → 报告 Agent | 3 |
+| **Debate(辩论模式)** | 正方立论 → 反方反驳 → 正方回应 → 反方回应 → 裁判评判 | 3 |
+
+所有 Agent 均调用真实 LLM(DeepSeek),使用真实工具(Tavily 搜索、Python 执行、数学计算),无任何 Mock 数据。
+
+---
+
+## 项目结构
+
+```
+multi-agent-demo/
+├── config.py          # 配置中心(API Key、模型、端口)
+├── agent.py           # Agent 基类(LLM 调用 + 工具循环)
+├── orchestrator.py    # 三种模式的执行引擎
+├── tools.py           # 工具注册表(web_search、calculator、run_python)
+├── server.py          # FastAPI 后端(SSE 实时推送)
+└── index.html         # 前端页面(液态玻璃 UI)
+```
+
+---
+
+## 环境要求
+
+- **Python** >= 3.11
+- **uv** 包管理器(推荐)或 pip
+- **DeepSeek API Key**(或其他兼容 OpenAI 接口的 LLM)
+- **Tavily API Key**(用于 web_search 工具)
+
+---
+
+## 配置说明
+
+所有配置集中在 `config.py`,支持环境变量覆盖。
+
+### 配置项一览
+
+| 配置项 | 环境变量 | 默认值 | 说明 |
+|--------|---------|--------|------|
+| LLM API Key | `LLM_API_KEY` | 内置 Key | DeepSeek API Key |
+| LLM Base URL | `LLM_BASE_URL` | `https://api.deepseek.com/v1` | OpenAI 兼容接口地址 |
+| LLM 模型 | `LLM_MODEL` | `deepseek-v4-flash` | 模型名称 |
+| Tavily API Key | `TAVILY_API_KEY` | 内置 Key | 搜索工具 Key |
+| 应用端口 | `APP_PORT` | `8900` | Web 服务端口 |
+
+### 方式一:修改 config.py(直接改默认值)
+
+```python
+LLM_API_KEY    = os.getenv("LLM_API_KEY", "你的API Key")
+LLM_BASE_URL   = os.getenv("LLM_BASE_URL", "https://你的接口地址/v1")
+LLM_MODEL      = os.getenv("LLM_MODEL", "你的模型名")
+TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "你的Tavily Key")
+```
+
+### 方式二:设置环境变量(推荐,不改代码)
+
+```bash
+export LLM_API_KEY="sk-xxx"
+export LLM_BASE_URL="https://api.deepseek.com/v1"
+export LLM_MODEL="deepseek-chat"
+export TAVILY_API_KEY="tvly-xxx"
+export APP_PORT="8900"
+```
+
+---
+
+## 服务启动
+
+### 使用 uv(推荐)
+
+```bash
+cd multi-agent-demo
+
+uv run --python 3.11 \
+  --with fastapi \
+  --with uvicorn \
+  --with pydantic \
+  --with openai \
+  --with tavily-python \
+  python server.py
+```
+
+### 使用 pip
+
+```bash
+cd multi-agent-demo
+
+# 创建虚拟环境
+python3.11 -m venv .venv
+source .venv/bin/activate
+
+# 安装依赖
+pip install fastapi uvicorn pydantic openai tavily-python
+
+# 启动
+python server.py
+```
+
+### 启动成功标志
+
+```
+INFO:     Started server process [xxxxx]
+INFO:     Waiting for application startup.
+INFO:     Application startup complete.
+INFO:     Uvicorn running on http://0.0.0.0:8900 (Press CTRL+C to quit)
+```
+
+浏览器访问 **http://localhost:8900** 即可打开演示页面。
+
+---
+
+## 依赖说明
+
+| 包名 | 用途 |
+|------|------|
+| `fastapi` | Web 框架 |
+| `uvicorn` | ASGI 服务器 |
+| `pydantic` | 数据校验 |
+| `openai` | LLM API 客户端(兼容所有 OpenAI 接口格式) |
+| `tavily-python` | 搜索工具 API |
+
+> `openai` 库用于调用所有兼容 OpenAI 接口的 LLM(DeepSeek、Moonshot、智谱等),不限于 OpenAI 的模型。
+
+---
+
+## 三种模式详解
+
+### Supervisor 模式
+
+```
+用户提问
+  ↓
+Supervisor 分析任务,拆解为子任务
+  ↓
+Researcher 执行 web_search 搜索真实信息
+  ↓
+Coder 执行 calculator / run_python 做数据分析
+  ↓
+Supervisor 整合所有结果,输出最终答案
+```
+
+### Pipeline 模式
+
+```
+用户提问
+  ↓
+搜索 Agent 采集信息(web_search)
+  ↓
+分析 Agent 结构化分析(calculator + run_python)
+  ↓
+报告 Agent 生成最终报告(run_python)
+```
+
+### Debate 模式
+
+```
+用户提出辩题
+  ↓
+正方辩手 搜索资料 + 立论
+  ↓
+反方辩手 搜索资料 + 反驳 + 立论
+  ↓
+正方辩手 回应反驳
+  ↓
+反方辩手 最终反驳
+  ↓
+裁判 综合评判,给出结论
+```
+
+---
+
+## 工具清单
+
+| 工具 | 功能 | 使用的 Agent |
+|------|------|-------------|
+| `web_search` | Tavily 搜索,每次返回 2 条结果 | Researcher、正/反方辩手、搜索 Agent |
+| `calculator` | 安全数学计算(加减乘除、幂运算) | Coder、裁判、分析 Agent |
+| `run_python` | 隔离执行 Python 代码(10 秒超时) | Coder、裁判、分析/报告 Agent |
+| `text_analyze` | 文本统计(字符数、词数、行数) | 可选 |
+
+---
+
+## 内置预设任务
+
+页面提供三个预设按钮,点击自动填入示例查询:
+
+- **Supervisor**:Agent 面试题调研 + 数据分析
+- **Pipeline**:LangChain / CrewAI / AutoGen 竞品分析报告
+- **Debate**:LangGraph vs 纯代码手写的技术选型辩论
+
+---
+
+## 实时执行追踪
+
+通过 SSE(Server-Sent Events)实时推送每个执行步骤:
+
+- LLM 调用:模型名、耗时(ms)、Token 数、当前轮次
+- 工具调用:工具名、输入参数、返回结果、耗时
+- 步骤状态:开始/结束标记、Agent 名称、执行动作
+- 最终结果:完整答案、总耗时
+
+---
+
+## 常见问题
+
+**端口被占用**
+
+```bash
+lsof -i:8900          # 查看占用进程
+kill -9 <PID>         # 杀掉进程
+# 或换端口
+export APP_PORT=9000
+```
+
+**DeepSeek API 报错**
+
+- 检查 API Key 是否有效
+- 确认 Base URL 以 `/v1` 结尾
+- 检查账户余额
+
+**Tavily 搜索失败**
+
+- 检查 Tavily API Key 是否有效
+- 免费版有调用次数限制
+- 代码内置 3 次重试机制
+
+**切换到其他 LLM**
+
+修改环境变量即可,无需改代码:
+
+```bash
+export LLM_BASE_URL="https://api.moonshot.cn/v1"
+export LLM_API_KEY="你的Key"
+export LLM_MODEL="moonshot-v1-8k"
+```

BIN
__pycache__/agent.cpython-311.pyc


BIN
__pycache__/config.cpython-311.pyc


BIN
__pycache__/orchestrator.cpython-311.pyc


BIN
__pycache__/tools.cpython-311.pyc


+ 65 - 0
agent.py

@@ -0,0 +1,65 @@
+"""Agent 基类 — 100% 真实 LLM 调用 + 真实工具执行"""
+import json, time
+from openai import OpenAI
+from config import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, MAX_TOOL_ROUNDS
+from tools import TOOL_SCHEMAS, execute_tool
+
+
+class Agent:
+    def __init__(self, name: str, icon: str, system_prompt: str, tool_names: list, model: str = None):
+        self.name = name
+        self.icon = icon
+        self.system_prompt = system_prompt
+        self.model = model or LLM_MODEL
+        self.tools = [TOOL_SCHEMAS[n] for n in tool_names if n in TOOL_SCHEMAS]
+        self.tool_names = tool_names
+        self.client = OpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
+
+    def chat(self, messages: list, emit=None) -> str:
+        """带工具循环的真实 LLM 调用"""
+        full_messages = [{"role": "system", "content": self.system_prompt}] + messages
+
+        for round_i in range(MAX_TOOL_ROUNDS):
+            t0 = time.time()
+            kwargs = {"model": self.model, "messages": full_messages, "temperature": 0.3, "max_tokens": 4000}
+            if self.tools:
+                kwargs["tools"] = self.tools
+                kwargs["tool_choice"] = "auto"
+
+            resp = self.client.chat.completions.create(**kwargs)
+            msg = resp.choices[0].message
+            latency = int((time.time() - t0) * 1000)
+            tokens = resp.usage.total_tokens if resp.usage else 0
+
+            if emit:
+                emit("llm_call", {"agent": self.name, "model": self.model, "latency_ms": latency, "tokens": tokens, "round": round_i + 1})
+
+            # 无工具调用 → 最终文本
+            if not msg.tool_calls:
+                final = msg.content or ""
+                if emit:
+                    emit("final_answer", {"agent": self.name, "answer": final, "rounds": round_i + 1})
+                return final
+
+            # 有工具调用 → 逐个执行
+            full_messages.append(msg)
+            for tc in msg.tool_calls:
+                fn_name = tc.function.name
+                try:
+                    fn_args = json.loads(tc.function.arguments)
+                except json.JSONDecodeError:
+                    fn_args = {}
+
+                t1 = time.time()
+                result = execute_tool(fn_name, fn_args)
+                tool_ms = int((time.time() - t1) * 1000)
+
+                if emit:
+                    emit("tool_call", {
+                        "agent": self.name, "tool": fn_name,
+                        "args": fn_args, "result": result, "latency_ms": tool_ms,
+                    })
+
+                full_messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
+
+        return "[达到最大工具调用轮次限制]"

+ 14 - 0
config.py

@@ -0,0 +1,14 @@
+"""配置中心"""
+import os
+
+# DeepSeek V4 Flash
+LLM_API_KEY  = os.getenv("LLM_API_KEY", "sk-5fe08380dd6048348f165c82e120dd1d")
+LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1")
+LLM_MODEL    = os.getenv("LLM_MODEL", "deepseek-v4-flash")
+
+# Tavily 搜索
+TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "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")
+
+# 应用配置
+APP_PORT = int(os.getenv("APP_PORT", "8900"))
+MAX_TOOL_ROUNDS = 999

+ 570 - 0
index.html

@@ -0,0 +1,570 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Multi-Agent 实战演示</title>
+<style>
+:root {
+  --bg: #0a0a12;
+  --glass: rgba(255,255,255,0.06);
+  --glass-border: rgba(255,255,255,0.1);
+  --glass-highlight: rgba(255,255,255,0.15);
+  --text: #e8eaed;
+  --text-dim: #8a8d93;
+  --accent: #00E5A0;
+  --accent-dim: rgba(0,229,160,0.15);
+  --blue: #58a6ff;
+  --purple: #bc8cff;
+  --yellow: #d29922;
+  --red: #f85149;
+  --radius: 20px;
+  --blur: 40px;
+}
+
+* { margin:0; padding:0; box-sizing:border-box; }
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', sans-serif;
+  background: var(--bg);
+  color: var(--text);
+  min-height: 100vh;
+  overflow-x: hidden;
+  -webkit-font-smoothing: antialiased;
+}
+
+/* ── Animated Background Orbs ── */
+.bg-orbs { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
+.orb {
+  position: absolute; border-radius: 50%;
+  filter: blur(120px); opacity: 0.4;
+  animation: float 20s ease-in-out infinite;
+}
+.orb-1 { width: 600px; height: 600px; background: #00E5A0; top: -15%; left: -10%; animation-delay: 0s; }
+.orb-2 { width: 500px; height: 500px; background: #58a6ff; bottom: -10%; right: -8%; animation-delay: -7s; }
+.orb-3 { width: 400px; height: 400px; background: #bc8cff; top: 50%; left: 50%; transform: translate(-50%,-50%); animation-delay: -14s; }
+@keyframes float {
+  0%, 100% { transform: translate(0, 0) scale(1); }
+  33% { transform: translate(30px, -40px) scale(1.05); }
+  66% { transform: translate(-20px, 30px) scale(0.95); }
+}
+
+/* ── Liquid Glass Card ── */
+.glass {
+  position: relative;
+  background: var(--glass);
+  backdrop-filter: blur(var(--blur)) saturate(2) brightness(1.08);
+  -webkit-backdrop-filter: blur(var(--blur)) saturate(2) brightness(1.08);
+  border-radius: var(--radius);
+  overflow: hidden;
+  box-shadow:
+    inset 0 1px 0 rgba(255,255,255,0.15),
+    inset 0 -0.5px 0 rgba(255,255,255,0.05),
+    0 0 0 0.5px rgba(255,255,255,0.06),
+    0 8px 32px rgba(0,0,0,0.3);
+}
+.glass::before {
+  content: '';
+  position: absolute; inset: 0;
+  background: linear-gradient(
+    160deg,
+    rgba(255,255,255,0.12) 0%,
+    rgba(255,255,255,0.04) 25%,
+    transparent 50%,
+    rgba(255,255,255,0.02) 75%,
+    rgba(255,255,255,0.06) 100%
+  );
+  border-radius: inherit;
+  pointer-events: none;
+  z-index: 1;
+}
+
+/* ── Refraction Highlight (animated) ── */
+.glass::after {
+  content: '';
+  position: absolute; inset: -50%;
+  background: conic-gradient(
+    from 0deg,
+    transparent 0deg,
+    rgba(255,255,255,0.08) 60deg,
+    transparent 120deg,
+    rgba(0,229,160,0.04) 180deg,
+    transparent 240deg,
+    rgba(88,166,255,0.06) 300deg,
+    transparent 360deg
+  );
+  animation: refract 12s linear infinite;
+  pointer-events: none;
+  z-index: 1;
+  opacity: 0.6;
+}
+@keyframes refract { to { transform: rotate(360deg); } }
+
+.glass > * { position: relative; z-index: 2; }
+
+/* ── Layout ── */
+.container { max-width: 1080px; margin: 0 auto; padding: 40px 24px; position: relative; z-index: 1; }
+
+/* ── Header ── */
+.header { text-align: center; margin-bottom: 40px; }
+.header h1 {
+  font-size: 36px; font-weight: 800; letter-spacing: -1px;
+  background: linear-gradient(135deg, #fff 0%, #00E5A0 50%, #58a6ff 100%);
+  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
+  background-clip: text;
+}
+.header p { color: var(--text-dim); font-size: 15px; margin-top: 8px; }
+
+/* ── Mode Tabs ── */
+.mode-tabs {
+  display: flex; gap: 6px; margin-bottom: 28px;
+  padding: 6px; border-radius: 16px;
+}
+.mode-tab {
+  flex: 1; padding: 14px 10px; border: none; border-radius: 12px;
+  background: transparent; color: var(--text-dim); cursor: pointer;
+  font-size: 13px; font-weight: 600; transition: all .25s ease;
+  display: flex; flex-direction: column; align-items: center; gap: 5px;
+}
+.mode-tab .m-icon { font-size: 22px; }
+.mode-tab:hover { color: var(--text); background: rgba(255,255,255,0.04); }
+.mode-tab.active {
+  background: rgba(0,229,160,0.12);
+  color: var(--accent);
+  box-shadow: inset 0 1px 0 rgba(0,229,160,0.2), 0 0 0 0.5px rgba(0,229,160,0.15);
+}
+.mode-tab .m-desc { font-size: 10px; font-weight: 400; opacity: 0.6; }
+
+/* ── Mode Info ── */
+.mode-info {
+  padding: 20px 24px; margin-bottom: 20px;
+  display: flex; align-items: flex-start; gap: 16px; font-size: 14px; line-height: 1.7;
+}
+.mode-info .mi-icon { font-size: 36px; flex-shrink: 0; }
+.mode-info .mi-title { font-weight: 700; font-size: 16px; margin-bottom: 4px; }
+.mode-info .mi-flow {
+  margin-top: 10px; font-family: 'SF Mono', 'Fira Code', monospace;
+  font-size: 12px; color: var(--accent); padding: 8px 12px;
+  background: rgba(0,0,0,0.3); border-radius: 8px;
+  white-space: pre; overflow-x: auto;
+}
+
+/* ── Presets ── */
+.presets { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
+.preset-btn {
+  padding: 8px 16px; border-radius: 10px; border: none;
+  background: rgba(255,255,255,0.04); color: var(--text-dim);
+  font-size: 12px; cursor: pointer; transition: all .2s;
+  box-shadow: inset 0 0.5px 0 rgba(255,255,255,0.08), 0 0 0 0.5px rgba(255,255,255,0.05);
+}
+.preset-btn:hover { color: var(--accent); background: rgba(0,229,160,0.06); border-color: rgba(0,229,160,0.2); }
+.preset-btn.active { color: var(--accent); background: rgba(0,229,160,0.08); }
+
+/* ── Input ── */
+.input-area { display: flex; gap: 10px; margin-bottom: 32px; }
+.input-area textarea {
+  flex: 1; padding: 16px 18px; border-radius: 14px; border: none;
+  background: rgba(255,255,255,0.06);
+  color: var(--text); font-size: 14px; font-family: inherit;
+  resize: none; height: 56px; outline: none;
+  box-shadow: inset 0 0.5px 0 rgba(255,255,255,0.1), 0 0 0 0.5px rgba(255,255,255,0.06);
+  backdrop-filter: blur(20px);
+  transition: all .2s;
+}
+.input-area textarea:focus {
+  box-shadow: inset 0 0.5px 0 rgba(255,255,255,0.1), 0 0 0 2px rgba(0,229,160,0.2), 0 0 0 0.5px rgba(0,229,160,0.3);
+}
+.run-btn {
+  padding: 0 32px; border-radius: 14px; border: none;
+  background: linear-gradient(180deg, #00E5A0 0%, #00C98A 100%);
+  color: #0a0a12; font-size: 14px; font-weight: 700; cursor: pointer;
+  transition: all .2s; white-space: nowrap;
+  box-shadow: inset 0 0.5px 0 rgba(255,255,255,0.3), 0 2px 12px rgba(0,229,160,0.3);
+}
+.run-btn:hover { transform: translateY(-1px); box-shadow: inset 0 0.5px 0 rgba(255,255,255,0.3), 0 4px 20px rgba(0,229,160,0.4); }
+.run-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
+
+/* ── Trace Area ── */
+.trace-area { display: none; margin-bottom: 28px; }
+.trace-area.visible { display: block; animation: fadeUp .4s ease; }
+@keyframes fadeUp { from { opacity: 0; transform: translateY(16px); } }
+
+.trace-header {
+  display: flex; align-items: center; gap: 8px; margin-bottom: 16px;
+  font-size: 14px; font-weight: 700;
+}
+.trace-header .th-dot {
+  width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
+  animation: pulse 1.5s ease infinite;
+}
+@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
+
+/* ── Step Card ── */
+.step-cards { display: flex; flex-direction: column; gap: 12px; }
+
+.step {
+  padding: 18px 20px;
+  animation: stepIn .35s ease both;
+}
+@keyframes stepIn { from { opacity: 0; transform: translateX(-12px); } }
+
+.step-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
+.step-icon { font-size: 20px; }
+.step-agent { font-weight: 700; font-size: 14px; }
+.step-action { font-size: 13px; color: var(--text-dim); flex: 1; text-align: right; }
+
+.step-events { display: flex; flex-direction: column; gap: 8px; }
+
+.event {
+  padding: 10px 14px; border-radius: 10px;
+  background: rgba(0,0,0,0.2);
+  font-size: 12px; line-height: 1.6;
+}
+.event-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
+.event-tag {
+  font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px;
+  text-transform: uppercase; letter-spacing: 0.5px;
+}
+.event-tag.llm { background: rgba(88,166,255,0.15); color: var(--blue); }
+.event-tag.tool { background: rgba(188,140,255,0.15); color: var(--purple); }
+.event-tag.answer { background: rgba(0,229,160,0.15); color: var(--accent); }
+.event-meta { font-size: 11px; color: var(--text-dim); margin-left: auto; }
+.event-body { color: var(--text-dim); font-size: 12px; max-height: 120px; overflow: hidden; position: relative; }
+.event-body.expanded { max-height: none; }
+.event-expand {
+  display: inline-block; margin-top: 4px; font-size: 11px; color: var(--accent);
+  cursor: pointer; background: none; border: none;
+}
+
+/* ── Final Answer ── */
+.final-answer {
+  padding: 24px; margin-top: 20px;
+  border: 1px solid rgba(0,229,160,0.15);
+}
+.final-answer .fa-label {
+  font-size: 12px; font-weight: 700; color: var(--accent);
+  text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px;
+}
+.final-answer .fa-content {
+  font-size: 14px; line-height: 1.8; white-space: pre-wrap;
+}
+
+/* ── Stats Bar ── */
+.stats-bar {
+  display: flex; gap: 16px; flex-wrap: wrap; margin-top: 16px; padding: 16px 20px;
+}
+.stat { text-align: center; min-width: 80px; }
+.stat-val { font-size: 24px; font-weight: 800; color: var(--accent); }
+.stat-label { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
+
+/* ── Loading ── */
+.loading-dots { display: inline-flex; gap: 4px; align-items: center; }
+.loading-dots span {
+  width: 4px; height: 4px; border-radius: 50%; background: var(--accent);
+  animation: dotBounce 1.2s ease infinite;
+}
+.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
+.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
+@keyframes dotBounce { 0%,80%,100% { opacity: 0.2; } 40% { opacity: 1; } }
+
+/* ── Responsive ── */
+@media (max-width: 768px) {
+  .header h1 { font-size: 26px; }
+  .mode-tabs { flex-wrap: wrap; }
+  .input-area { flex-direction: column; }
+  .stats-bar { justify-content: center; }
+}
+
+::-webkit-scrollbar { width: 6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
+</style>
+</head>
+<body>
+
+<div class="bg-orbs">
+  <div class="orb orb-1"></div>
+  <div class="orb orb-2"></div>
+  <div class="orb orb-3"></div>
+</div>
+
+<div class="container">
+  <div class="header">
+    <h1>Multi-Agent 实战演示</h1>
+    <p>DeepSeek API 真实调用 · 工具真实执行 · 全链路 trace</p>
+  </div>
+
+  <!-- Mode Tabs -->
+  <div class="glass mode-tabs" id="modeTabs">
+    <button class="mode-tab active" data-mode="supervisor" onclick="switchMode('supervisor')">
+      <span class="m-icon">👔</span>Supervisor<span class="m-desc">总控 + 专家</span>
+    </button>
+    <button class="mode-tab" data-mode="pipeline" onclick="switchMode('pipeline')">
+      <span class="m-icon">🔗</span>Pipeline<span class="m-desc">线性流水线</span>
+    </button>
+    <button class="mode-tab" data-mode="debate" onclick="switchMode('debate')">
+      <span class="m-icon">⚖️</span>Debate<span class="m-desc">正反方辩论</span>
+    </button>
+  </div>
+
+  <!-- Mode Info -->
+  <div class="glass mode-info" id="modeInfo"></div>
+
+  <!-- Presets -->
+  <div class="presets" id="presets"></div>
+
+  <!-- Input -->
+  <div class="input-area">
+    <textarea id="queryInput" placeholder="输入任务描述,或点击上方预设场景..." rows="2"></textarea>
+    <button class="run-btn" id="runBtn" onclick="execute()">▶ 执行</button>
+  </div>
+
+  <!-- Trace -->
+  <div class="trace-area" id="traceArea">
+    <div class="trace-header">
+      <div class="th-dot" id="traceDot"></div>
+      <span id="traceTitle">执行中...</span>
+      <span style="margin-left:auto;font-size:12px;color:var(--text-dim)" id="traceTimer"></span>
+    </div>
+    <div class="step-cards" id="stepCards"></div>
+    <div id="finalSection"></div>
+  </div>
+</div>
+
+<script>
+const MODE_INFO = {
+  supervisor: {
+    icon: '👔', title: 'Supervisor 模式',
+    desc: '总控 Agent 拆解任务,路由给研究专家和代码专家,最后整合结果。职责清晰,易调试,可扩展。',
+    flow: 'User → Supervisor(拆任务) → Researcher(搜索) → Coder(分析) → Supervisor(整合) → Answer',
+  },
+  pipeline: {
+    icon: '🔗', title: 'Pipeline 管道模式',
+    desc: '线性流水线:搜索 Agent 采集信息 → 分析 Agent 处理数据 → 报告 Agent 生成最终报告。流程固定,确定性高。',
+    flow: 'User → 搜索Agent(web_search) → 分析Agent(python) → 报告Agent(python) → Answer',
+  },
+  debate: {
+    icon: '⚖️', title: 'Debate 对抗模式',
+    desc: '正方 Agent 和反方 Agent 各自搜索论据,经过两轮交锋后,裁判 Agent 综合评判给出结论。',
+    flow: 'User → 正方(搜索+立论) ⟷ 反方(搜索+反驳) → 裁判(评判) → Verdict',
+  },
+};
+
+let currentMode = 'supervisor';
+let presets = {};
+let isRunning = false;
+let timerInterval = null;
+
+async function init() {
+  const resp = await fetch('/api/presets');
+  presets = await resp.json();
+  switchMode('supervisor');
+}
+
+function switchMode(mode) {
+  currentMode = mode;
+  document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
+  document.querySelector(`.mode-tab[data-mode="${mode}"]`).classList.add('active');
+
+  const info = MODE_INFO[mode];
+  document.getElementById('modeInfo').innerHTML = `
+    <span class="mi-icon">${info.icon}</span>
+    <div>
+      <div class="mi-title">${info.title}</div>
+      <div>${info.desc}</div>
+      <div class="mi-flow">${info.flow}</div>
+    </div>`;
+
+  // Presets
+  const p = presets[mode];
+  document.getElementById('presets').innerHTML = p
+    ? `<button class="preset-btn active" onclick="fillPreset('${mode}')">${p.title}</button>`
+    : '';
+  if (p) document.getElementById('queryInput').value = p.query;
+}
+
+function fillPreset(mode) {
+  const p = presets[mode];
+  if (p) document.getElementById('queryInput').value = p.query;
+}
+
+async function execute() {
+  const query = document.getElementById('queryInput').value.trim();
+  if (!query || isRunning) return;
+
+  isRunning = true;
+  const btn = document.getElementById('runBtn');
+  btn.disabled = true;
+  btn.textContent = '⏳ 执行中...';
+
+  const traceArea = document.getElementById('traceArea');
+  const stepCards = document.getElementById('stepCards');
+  const finalSection = document.getElementById('finalSection');
+  const traceDot = document.getElementById('traceDot');
+  const traceTitle = document.getElementById('traceTitle');
+  const traceTimer = document.getElementById('traceTimer');
+
+  traceArea.classList.add('visible');
+  stepCards.innerHTML = '';
+  finalSection.innerHTML = '';
+  traceDot.style.background = 'var(--accent)';
+  traceDot.style.animation = 'pulse 1.5s ease infinite';
+  traceTitle.textContent = `${MODE_INFO[currentMode].title} — 执行中`;
+
+  // Timer
+  let startTime = Date.now();
+  timerInterval = setInterval(() => {
+    traceTimer.textContent = `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
+  }, 100);
+
+  try {
+    const resp = await fetch('/api/execute', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ mode: currentMode, query }),
+    });
+
+    const reader = resp.body.getReader();
+    const decoder = new TextDecoder();
+    let buffer = '';
+    let currentStepEl = null;
+    let totalLlmCalls = 0;
+    let totalToolCalls = 0;
+    let totalTokens = 0;
+
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      buffer += decoder.decode(value, { stream: true });
+      const lines = buffer.split('\n');
+      buffer = lines.pop();
+
+      for (const line of lines) {
+        if (!line.startsWith('data: ')) continue;
+        const event = JSON.parse(line.slice(6));
+
+        if (event.type === 'step_start') {
+          currentStepEl = createStepCard(event);
+          stepCards.appendChild(currentStepEl);
+          currentStepEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+        }
+        else if (event.type === 'agent_event' && currentStepEl) {
+          if (event.tool) {
+            totalToolCalls++;
+            appendEvent(currentStepEl, event);
+          } else if (event.answer) {
+            appendEvent(currentStepEl, event);
+          } else {
+            totalLlmCalls++;
+            if (event.tokens) totalTokens += event.tokens;
+            appendEvent(currentStepEl, event);
+          }
+        }
+        else if (event.type === 'step_end' && currentStepEl) {
+          // Mark step complete
+          const head = currentStepEl.querySelector('.step-action');
+          if (head) head.textContent = '✅ 完成';
+        }
+        else if (event.type === 'final') {
+          traceDot.style.background = 'var(--accent)';
+          traceDot.style.animation = 'none';
+          traceTitle.textContent = `${MODE_INFO[currentMode].title} — 完成`;
+
+          finalSection.innerHTML = `
+            <div class="glass final-answer">
+              <div class="fa-label">📋 最终结果</div>
+              <div class="fa-content">${escHtml(event.answer)}</div>
+            </div>
+            <div class="glass stats-bar">
+              <div class="stat"><div class="stat-val">${totalLlmCalls}</div><div class="stat-label">LLM 调用</div></div>
+              <div class="stat"><div class="stat-val">${totalToolCalls}</div><div class="stat-label">工具调用</div></div>
+              <div class="stat"><div class="stat-val">${totalTokens.toLocaleString()}</div><div class="stat-label">Tokens</div></div>
+              <div class="stat"><div class="stat-val">${(event.total_ms / 1000).toFixed(1)}s</div><div class="stat-label">总耗时</div></div>
+            </div>`;
+          finalSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+        }
+        else if (event.type === 'error') {
+          traceDot.style.background = 'var(--red)';
+          traceTitle.textContent = '执行出错';
+          finalSection.innerHTML = `<div class="glass final-answer" style="border-color:rgba(248,81,73,0.3)"><div class="fa-label" style="color:var(--red)">❌ 错误</div><div class="fa-content">${escHtml(event.message)}</div></div>`;
+        }
+      }
+    }
+  } catch (e) {
+    traceTitle.textContent = '连接失败';
+    finalSection.innerHTML = `<div class="glass final-answer" style="border-color:rgba(248,81,73,0.3)"><div class="fa-label" style="color:var(--red)">❌ 网络错误</div><div class="fa-content">${escHtml(e.message)}</div></div>`;
+  } finally {
+    clearInterval(timerInterval);
+    isRunning = false;
+    btn.disabled = false;
+    btn.textContent = '▶ 执行';
+  }
+}
+
+function createStepCard(event) {
+  const el = document.createElement('div');
+  el.className = 'glass step';
+  el.innerHTML = `
+    <div class="step-head">
+      <span class="step-icon">${event.icon || '🤖'}</span>
+      <span class="step-agent">${escHtml(event.agent)}</span>
+      <span class="step-action">${escHtml(event.action)} <span class="loading-dots"><span></span><span></span><span></span></span></span>
+    </div>
+    <div class="step-events"></div>`;
+  return el;
+}
+
+function appendEvent(stepEl, event) {
+  const container = stepEl.querySelector('.step-events');
+  const el = document.createElement('div');
+  el.className = 'event';
+
+  if (event.tool) {
+    const fullResult = escHtml(event.result || '');
+    const hasMore = fullResult.length > 150;
+    const shortResult = hasMore ? fullResult.slice(0, 150) + '...' : fullResult;
+    el.innerHTML = `
+      <div class="event-header">
+        <span class="event-tag tool">🔧 ${escHtml(event.tool)}</span>
+        <span class="event-meta">${event.latency_ms || 0}ms</span>
+      </div>
+      <div class="event-body">${shortResult}</div>
+      ${hasMore ? '<button class="event-expand" onclick="toggleExpand(this)">展开</button>' : ''}`;
+    if (hasMore) {
+      el.dataset.fullResult = fullResult;
+    }
+  } else {
+    el.innerHTML = `
+      <div class="event-header">
+        <span class="event-tag llm">🧠 ${escHtml(event.model || 'LLM')}</span>
+        <span class="event-meta">${event.latency_ms || 0}ms · ${(event.tokens || 0).toLocaleString()} tokens</span>
+      </div>`;
+  }
+  container.appendChild(el);
+}
+
+function toggleExpand(btn) {
+  const body = btn.previousElementSibling;
+  const eventEl = btn.closest('.event');
+  if (body.classList.contains('expanded')) {
+    body.classList.remove('expanded');
+    body.innerHTML = body.dataset.shortResult || body.textContent.slice(0, 150) + '...';
+    btn.textContent = '展开';
+  } else {
+    body.dataset.shortResult = body.innerHTML;
+    body.innerHTML = eventEl.dataset.fullResult || body.textContent;
+    body.classList.add('expanded');
+    btn.textContent = '收起';
+  }
+}
+
+function escHtml(s) {
+  if (!s) return '';
+  const d = document.createElement('div');
+  d.textContent = s;
+  return d.innerHTML;
+}
+
+init();
+</script>
+</body>
+</html>

+ 290 - 0
orchestrator.py

@@ -0,0 +1,290 @@
+"""三种 Multi-Agent 模式的真实执行引擎 — 全部真实 LLM 调用"""
+import json, time, uuid
+from agent import Agent
+
+# ── Agent 定义 ──
+
+SUPERVISOR = Agent(
+    name="Supervisor", icon="👔",
+    system_prompt=(
+        "你是多 Agent 团队的主管。你的职责是拆解任务、分配给专家、整合结果。\n\n"
+        "你有两个专家:\n"
+        "- Researcher: 信息搜索专家,擅长用 web_search 搜索真实信息\n"
+        "- Coder: 数据分析专家,擅长用 calculator 和 run_python 做计算和分析\n\n"
+        "工作流程:\n"
+        "1. 收到任务后,先分析需要哪些子任务\n"
+        "2. 把子任务分配给合适的专家(通过给专家发送清晰的指令)\n"
+        "3. 收到专家结果后,整合成高质量的最终答案\n\n"
+        "重要:你不要自己搜索或计算,必须委托给专家。你只负责调度和整合。"
+    ),
+    tool_names=[],
+)
+
+RESEARCHER = Agent(
+    name="Researcher", icon="🔍",
+    system_prompt=(
+        "你是信息搜索专家。你的唯一工具是 web_search。\n\n"
+        "规则:\n"
+        "1. 收到任务后,用 web_search 搜索,只搜索 1-2 次,不要重复搜索\n"
+        "2. 第一次搜索用核心关键词,第二次只在第一次结果不够时才搜\n"
+        "3. 返回结构化的搜索结果,包含关键数据\n"
+        "4. 不要编造数据,搜不到就说搜不到\n"
+        "5. 搜索完成后立即返回结果,不要继续搜索"
+    ),
+    tool_names=["web_search"],
+)
+
+CODER = Agent(
+    name="Coder", icon="💻",
+    system_prompt=(
+        "你是数据分析师。你的工具是 calculator 和 run_python。\n\n"
+        "规则:\n"
+        "1. 收到数据后,用 calculator 做简单计算,用 run_python 做复杂分析\n"
+        "2. 返回清晰的计算结果和分析结论\n"
+        "3. 数据来自上一步的搜索结果,不要自己搜索\n"
+        "4. 如果需要生成报告,用 run_python 格式化输出"
+    ),
+    tool_names=["calculator", "run_python"],
+)
+
+DEBATE_PRO = Agent(
+    name="正方辩手", icon="🟢",
+    system_prompt=(
+        "你是正方辩手。针对给定辩题,你需要搜索资料并提出支持性论点。\n\n"
+        "规则:\n"
+        "1. 用 web_search 搜索真实案例和数据支持你的论点\n"
+        "2. 每个论点要有事实依据,不能空谈\n"
+        "3. 提出 2-3 个核心论点,每个论点配真实数据\n"
+        "4. 论证要有力,语言要有说服力"
+    ),
+    tool_names=["web_search"],
+)
+
+DEBATE_CON = Agent(
+    name="反方辩手", icon="🔴",
+    system_prompt=(
+        "你是反方辩手。针对给定辩题,你需要搜索资料并提出反对性论点。\n\n"
+        "规则:\n"
+        "1. 用 web_search 搜索真实案例和数据支持你的论点\n"
+        "2. 每个论点要有事实依据,不能空谈\n"
+        "3. 提出 2-3 个核心论点,每个论点配真实数据\n"
+        "4. 要有力反驳正方观点,论证要犀利"
+    ),
+    tool_names=["web_search"],
+)
+
+DEBATE_JUDGE = Agent(
+    name="裁判", icon="⚖️",
+    system_prompt=(
+        "你是中立裁判,综合正反双方的论据做出评判。\n\n"
+        "规则:\n"
+        "1. 用 calculator 或 run_python 做数据验证\n"
+        "2. 逐点评判双方论据的强弱\n"
+        "3. 给出结论,说明胜方理由\n"
+        "4. 保持客观,不偏袒任何一方"
+    ),
+    tool_names=["calculator", "run_python"],
+)
+
+PIPELINE_SEARCHER = Agent(
+    name="搜索 Agent", icon="🔍",
+    system_prompt=(
+        "你是信息搜索专家。\n\n"
+        "规则:\n"
+        "1. 用 web_search 搜索任务所需的信息\n"
+        "2. 搜索多个角度,确保信息全面\n"
+        "3. 返回结构化的搜索结果,标注来源\n"
+        "4. 如果一次搜索不够,可以多次搜索"
+    ),
+    tool_names=["web_search"],
+)
+
+PIPELINE_ANALYZER = Agent(
+    name="分析 Agent", icon="📊",
+    system_prompt=(
+        "你是数据分析专家。\n\n"
+        "规则:\n"
+        "1. 接收搜索 Agent 的结果\n"
+        "2. 用 run_python 做数据处理和对比分析\n"
+        "3. 用 calculator 做数值计算\n"
+        "4. 输出结构化的分析结论"
+    ),
+    tool_names=["calculator", "run_python"],
+)
+
+PIPELINE_REPORTER = Agent(
+    name="报告 Agent", icon="📝",
+    system_prompt=(
+        "你是报告撰写专家。\n\n"
+        "规则:\n"
+        "1. 接收分析 Agent 的结论\n"
+        "2. 用 run_python 生成格式化报告\n"
+        "3. 报告要有:标题、摘要、正文、结论、建议\n"
+        "4. 语言专业但易读"
+    ),
+    tool_names=["run_python"],
+)
+
+# ── 执行引擎 ──
+
+def _emit(emit, event_type, data):
+    if emit:
+        data["type"] = event_type
+        data["timestamp"] = time.time()
+        emit(data)
+
+
+def run_supervisor(query: str, emit=None) -> dict:
+    """Supervisor 模式:主管拆任务 → Researcher + Coder → 主管整合"""
+    trace_id = str(uuid.uuid4())[:8]
+    t_start = time.time()
+
+    def agent_emit(event_type, data):
+        data["trace_id"] = trace_id
+        data["mode"] = "supervisor"
+        _emit(emit, "agent_event", data)
+
+    # Step 1: Supervisor 分析任务
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "Supervisor", "icon": "👔", "action": "分析任务并拆解"})
+    supervisor_analysis = SUPERVISOR.chat(
+        [{"role": "user", "content": f"任务:{query}\n\n请分析这个任务,拆成子任务,说明每个子任务交给哪个专家。"}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "Supervisor", "result": supervisor_analysis[:500]})
+
+    # Step 2: Researcher 搜索信息
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "Researcher", "icon": "🔍", "action": "搜索信息"})
+    research_result = RESEARCHER.chat(
+        [{"role": "user", "content": f"请搜索以下任务所需的信息:\n{query}\n\n{supervisor_analysis}"}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "Researcher", "result": research_result[:500]})
+
+    # Step 3: Coder 分析计算
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "Coder", "icon": "💻", "action": "数据分析与计算"})
+    coder_result = CODER.chat(
+        [{"role": "user", "content": f"原始任务:{query}\n\n研究结果:\n{research_result}\n\n请基于以上数据做分析和计算。"}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "Coder", "result": coder_result[:500]})
+
+    # Step 4: Supervisor 整合
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "Supervisor", "icon": "👔", "action": "整合最终答案"})
+    final_answer = SUPERVISOR.chat(
+        [{"role": "user", "content": (
+            f"原始任务:{query}\n\n"
+            f"研究专家的结果:\n{research_result}\n\n"
+            f"数据分析的结果:\n{coder_result}\n\n"
+            f"请整合以上信息,给出最终的完整答案。"
+        )}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "Supervisor", "result": final_answer[:800]})
+
+    total_ms = int((time.time() - t_start) * 1000)
+    _emit(emit, "trace_complete", {"trace_id": trace_id, "mode": "supervisor", "total_ms": total_ms})
+
+    return {"trace_id": trace_id, "answer": final_answer, "total_ms": total_ms}
+
+
+def run_pipeline(query: str, emit=None) -> dict:
+    """Pipeline 模式:搜索 → 分析 → 报告"""
+    trace_id = str(uuid.uuid4())[:8]
+    t_start = time.time()
+
+    def agent_emit(event_type, data):
+        data["trace_id"] = trace_id
+        data["mode"] = "pipeline"
+        _emit(emit, "agent_event", data)
+
+    # Step 1: 搜索
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "搜索 Agent", "icon": "🔍", "action": "信息采集"})
+    search_result = PIPELINE_SEARCHER.chat(
+        [{"role": "user", "content": f"请搜索以下任务的信息:\n{query}"}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "搜索 Agent", "result": search_result[:500]})
+
+    # Step 2: 分析
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "分析 Agent", "icon": "📊", "action": "数据分析"})
+    analysis_result = PIPELINE_ANALYZER.chat(
+        [{"role": "user", "content": f"原始任务:{query}\n\n搜索结果:\n{search_result}\n\n请做数据分析。"}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "分析 Agent", "result": analysis_result[:500]})
+
+    # Step 3: 生成报告
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "报告 Agent", "icon": "📝", "action": "生成报告"})
+    report = PIPELINE_REPORTER.chat(
+        [{"role": "user", "content": f"原始任务:{query}\n\n分析结果:\n{analysis_result}\n\n请生成最终报告。"}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "报告 Agent", "result": report[:800]})
+
+    total_ms = int((time.time() - t_start) * 1000)
+    _emit(emit, "trace_complete", {"trace_id": trace_id, "mode": "pipeline", "total_ms": total_ms})
+
+    return {"trace_id": trace_id, "answer": report, "total_ms": total_ms}
+
+
+def run_debate(query: str, emit=None) -> dict:
+    """Debate 模式:正方 → 反方 → 二次交锋 → 裁判"""
+    trace_id = str(uuid.uuid4())[:8]
+    t_start = time.time()
+
+    def agent_emit(event_type, data):
+        data["trace_id"] = trace_id
+        data["mode"] = "debate"
+        _emit(emit, "agent_event", data)
+
+    # Step 1: 正方立论
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "正方辩手", "icon": "🟢", "action": "立论陈词"})
+    pro_round1 = DEBATE_PRO.chat(
+        [{"role": "user", "content": f"辩题:{query}\n\n你是正方,请搜索资料并提出你的核心论点。"}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "正方辩手", "result": pro_round1[:500]})
+
+    # Step 2: 反方反驳
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "反方辩手", "icon": "🔴", "action": "反驳 + 立论"})
+    con_round1 = DEBATE_CON.chat(
+        [{"role": "user", "content": f"辩题:{query}\n\n正方论点:\n{pro_round1}\n\n你是反方,请搜索资料反驳正方并提出你的论点。"}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "反方辩手", "result": con_round1[:500]})
+
+    # Step 3: 正方回应
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "正方辩手", "icon": "🟢", "action": "回应反驳"})
+    pro_round2 = DEBATE_PRO.chat(
+        [{"role": "user", "content": f"辩题:{query}\n\n你的论点:\n{pro_round1}\n\n反方反驳:\n{con_round1}\n\n请回应反驳并强化你的论点。"}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "正方辩手", "result": pro_round2[:500]})
+
+    # Step 4: 反方回应
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "反方辩手", "icon": "🔴", "action": "二次反驳"})
+    con_round2 = DEBATE_CON.chat(
+        [{"role": "user", "content": f"辩题:{query}\n\n你的论点:\n{con_round1}\n\n正方回应:\n{pro_round2}\n\n请做最终反驳。"}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "反方辩手", "result": con_round2[:500]})
+
+    # Step 5: 裁判评判
+    _emit(emit, "step_start", {"trace_id": trace_id, "agent": "裁判", "icon": "⚖️", "action": "综合评判"})
+    verdict = DEBATE_JUDGE.chat(
+        [{"role": "user", "content": (
+            f"辩题:{query}\n\n"
+            f"正方第一轮:\n{pro_round1}\n\n"
+            f"反方第一轮:\n{con_round1}\n\n"
+            f"正方第二轮:\n{pro_round2}\n\n"
+            f"反方第二轮:\n{con_round2}\n\n"
+            f"请综合评判,给出最终结论。"
+        )}],
+        emit=agent_emit
+    )
+    _emit(emit, "step_end", {"trace_id": trace_id, "agent": "裁判", "result": verdict[:800]})
+
+    total_ms = int((time.time() - t_start) * 1000)
+    _emit(emit, "trace_complete", {"trace_id": trace_id, "mode": "debate", "total_ms": total_ms})
+
+    return {"trace_id": trace_id, "answer": verdict, "total_ms": total_ms}

+ 96 - 0
server.py

@@ -0,0 +1,96 @@
+"""FastAPI 后端 — SSE 实时推送 Agent 执行 trace"""
+import json, asyncio
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse, StreamingResponse
+from pydantic import BaseModel
+from config import APP_PORT
+from orchestrator import run_supervisor, run_pipeline, run_debate
+
+app = FastAPI(title="Multi-Agent Demo")
+
+
+class ExecuteRequest(BaseModel):
+    mode: str  # supervisor | pipeline | debate
+    query: str
+
+
+MODE_RUNNERS = {
+    "supervisor": run_supervisor,
+    "pipeline": run_pipeline,
+    "debate": run_debate,
+}
+
+PRESETS = {
+    "supervisor": {
+        "title": "Agent 面试题调研 + 数据分析",
+        "query": "搜索 2025 年最新的 Agent 开发面试题,重点找 RAG、多 Agent 协同、上下文工程相关的题目,统计各方向的题目数量占比",
+    },
+    "pipeline": {
+        "title": "面试题竞品分析报告",
+        "query": "搜索 LangChain、CrewAI、AutoGen 三个多 Agent 框架的核心差异、社区活跃度、适用场景,生成一份结构化的对比分析报告",
+    },
+    "debate": {
+        "title": "技术选型辩论",
+        "query": "Agent 开发应该用 LangGraph 还是从零手撸?LangGraph 框架 vs 纯代码手写,哪种更适合生产环境?",
+    },
+}
+
+
+@app.get("/")
+async def index():
+    return FileResponse("index.html")
+
+
+@app.get("/api/presets")
+async def get_presets():
+    return PRESETS
+
+
+@app.post("/api/execute")
+async def execute(req: ExecuteRequest):
+    runner = MODE_RUNNERS.get(req.mode)
+    if not runner:
+        return {"error": f"Unknown mode: {req.mode}"}
+
+    async def stream():
+        queue = asyncio.Queue()
+
+        def emit(event):
+            queue.put_nowait(event)
+
+        # 在线程中运行(LLM 调用是阻塞的)
+        import threading
+        result_holder = {}
+
+        def run():
+            try:
+                result = runner(req.query, emit=emit)
+                result_holder["result"] = result
+            except Exception as e:
+                result_holder["error"] = str(e)
+            finally:
+                queue.put_nowait(None)  # 结束信号
+
+        thread = threading.Thread(target=run, daemon=True)
+        thread.start()
+
+        # 流式输出事件
+        while True:
+            event = await queue.get()
+            if event is None:
+                break
+            yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
+
+        # 发送最终结果
+        if "error" in result_holder:
+            yield f"data: {json.dumps({'type': 'error', 'message': result_holder['error']}, ensure_ascii=False)}\n\n"
+        elif "result" in result_holder:
+            yield f"data: {json.dumps({'type': 'final', **result_holder['result']}, ensure_ascii=False)}\n\n"
+
+    return StreamingResponse(stream(), media_type="text/event-stream")
+
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run(app, host="0.0.0.0", port=APP_PORT)

+ 163 - 0
tools.py

@@ -0,0 +1,163 @@
+"""工具注册表 — 每个工具是真实的可执行函数 + OpenAI function schema"""
+import json, subprocess, sys, tempfile, os, re, time
+
+try:
+    from tavily import TavilyClient
+except ImportError:
+    TavilyClient = None
+
+from config import TAVILY_API_KEY
+
+# ── 工具函数 ──
+
+def web_search(query: str, max_results: int = 2) -> str:
+    """真实调用 Tavily Search API(带重试)"""
+    if not TavilyClient:
+        return "[错误] tavily-python 未安装"
+    for attempt in range(3):
+        try:
+            client = TavilyClient(api_key=TAVILY_API_KEY)
+            resp = client.search(query=query, max_results=max_results, search_depth="basic")
+            results = resp.get("results", [])
+            if not results:
+                return "未找到相关结果"
+            output = []
+            for i, r in enumerate(results, 1):
+                title = r.get('title', '')
+                content = r.get('content', '')[:250]
+                output.append(f"[{i}] {title}\n    {content}")
+            return "\n\n".join(output)
+        except Exception as e:
+            if attempt < 2:
+                time.sleep(1)
+                continue
+            return f"[搜索错误] {e}"
+    return "[搜索错误] 重试3次均失败"
+
+
+def calculator(expression: str) -> str:
+    """安全数学计算"""
+    allowed = set("0123456789+-*/.() %")
+    cleaned = expression.replace("^", "**")
+    if not all(c in allowed or c.isalpha() for c in cleaned):
+        return "[错误] 不允许的字符"
+    try:
+        result = eval(cleaned, {"__builtins__": {}}, {"abs": abs, "round": round, "pow": pow})
+        return f"{expression} = {result}"
+    except Exception as e:
+        return f"[计算错误] {e}"
+
+
+def run_python(code: str) -> str:
+    """在隔离子进程中执行 Python 代码(限时 10 秒)"""
+    with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
+        f.write(code)
+        f.flush()
+        tmp_path = f.name
+    try:
+        result = subprocess.run(
+            [sys.executable, tmp_path],
+            capture_output=True, text=True, timeout=10,
+            env={**os.environ, 'PYTHONIOENCODING': 'utf-8'}
+        )
+        output = result.stdout
+        if result.returncode != 0:
+            output += f"\n[STDERR] {result.stderr}"
+        return output.strip() or "(无输出)"
+    except subprocess.TimeoutExpired:
+        return "[错误] 执行超时(10秒限制)"
+    finally:
+        os.unlink(tmp_path)
+
+
+def text_analyze(text: str) -> str:
+    """文本统计分析"""
+    words = text.split()
+    lines = text.strip().split('\n')
+    chars = len(text)
+    return (
+        f"字符数: {chars}\n"
+        f"词数: {len(words)}\n"
+        f"行数: {len(lines)}\n"
+        f"平均行长: {chars / max(len(lines), 1):.1f}"
+    )
+
+# ── OpenAI Function Schemas ──
+
+TOOL_SCHEMAS = {
+    "web_search": {
+        "type": "function",
+        "function": {
+            "name": "web_search",
+            "description": "搜索互联网获取实时信息。每次调用返回2条最相关的结果。建议一次搜索用精准关键词,避免多次搜索。",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "query": {"type": "string", "description": "搜索关键词,建议用英文或中英混合"},
+                    "max_results": {"type": "integer", "description": "返回结果数量,默认2", "default": 2}
+                },
+                "required": ["query"]
+            }
+        }
+    },
+    "calculator": {
+        "type": "function",
+        "function": {
+            "name": "calculator",
+            "description": "执行数学计算(支持加减乘除、幂运算、括号)",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "expression": {"type": "string", "description": "数学表达式,如 (3 + 5) * 7"}
+                },
+                "required": ["expression"]
+            }
+        }
+    },
+    "run_python": {
+        "type": "function",
+        "function": {
+            "name": "run_python",
+            "description": "执行 Python 代码并返回输出。用于数据处理、分析、生成报告等",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "code": {"type": "string", "description": "要执行的 Python 代码"}
+                },
+                "required": ["code"]
+            }
+        }
+    },
+    "text_analyze": {
+        "type": "function",
+        "function": {
+            "name": "text_analyze",
+            "description": "对文本进行统计分析(字符数、词数、行数等)",
+            "parameters": {
+                "type": "object",
+                "properties": {
+                    "text": {"type": "string", "description": "要分析的文本"}
+                },
+                "required": ["text"]
+            }
+        }
+    },
+}
+
+# 函数映射
+TOOL_FUNCTIONS = {
+    "web_search": web_search,
+    "calculator": calculator,
+    "run_python": run_python,
+    "text_analyze": text_analyze,
+}
+
+def execute_tool(name: str, args: dict) -> str:
+    """统一工具执行入口"""
+    fn = TOOL_FUNCTIONS.get(name)
+    if not fn:
+        return f"[错误] 未知工具: {name}"
+    try:
+        return fn(**args)
+    except Exception as e:
+        return f"[工具执行错误] {name}: {e}"