第33章 实战案例:从零搭建一个完整的Agent系统

第33章 实战案例:从零搭建一个完整的Agent系统

大白话: 前面学了这么多零件——Function Call是手、ReAct是脑子、工作流编排是团队协作、记忆是笔记本。现在该组装一辆能跑的车了。


33.1 项目背景:我们要做什么

需求描述

产品经理提了个需求:「做一个智能研究员Agent,给它一个研究主题,它能自动搜索资料、整理信息、写出一份结构化研究报告」

听起来简单,但拆开来看,这个Agent需要:

  1. 理解任务 — 用户说"帮我研究一下Rust语言在2024年的发展",Agent得知道要搜什么
  2. 搜索资料 — 调搜索引擎API找相关文章
  3. 获取网页内容 — 搜索结果只是标题和摘要,得把网页内容抓下来
  4. 信息提取 — 从一堆网页内容里提取跟主题相关的关键信息
  5. 去重整合 — 多篇文章可能有重复信息,得合并去重
  6. 结构化输出 — 不是把信息堆上去就行,得有逻辑结构(背景/现状/趋势/结论)
  7. 质量自检 — 写完报告自己审一遍,发现不足的地方补搜
  8. 引用标注 — 每个观点标明出处,不能空口白话

为什么这个项目有代表性

考察点本章涉及的章节
Function Call搜索/抓取/提取/保存,4个工具
ReAct模式Think-Act-Observe循环
工作流编排研究员→撰写者→审核员流水线
记忆机制短期对话+工作记忆+长期用户画像
RAG从搜索结果中检索相关信息
Prompt工程结构化报告的Prompt设计

一句话:这个项目把第6阶段学的全部用上了。


33.2 系统架构设计

整体架构图(文字描述)

用户输入研究主题
       │
       ▼
┌──────────────┐
│  研究员Agent  │ ← ReAct循环:搜索→抓取→提取→判断是否够了
│  (Researcher) │
└──────┬───────┘
       │ 输出:整理好的研究素材(带引用)
       ▼
┌──────────────┐
│ 撰写者Agent   │ ← 根据素材+报告模板,生成结构化报告
│  (Writer)     │
└──────┬───────┘
       │ 输出:报告初稿
       ▼
┌──────────────┐
│ 审核员Agent   │ ← 检查完整性/准确性/引用,决定通过还是打回
│  (Reviewer)   │
└──────┬───────┘
       │ 通过 → 输出最终报告
       │ 不通过 → 回到研究员Agent补充素材
       ▼
   最终报告(Markdown + 引用)

核心设计决策

决策点选择理由
架构模式流水线(非多Agent辩论)任务有明确阶段:研究→写→审,流水线最简单
研究员内部ReAct模式搜索是探索性的,不知道几轮才够,需要循环判断
审核不通过回到研究员补素材不是打回撰写者重写,而是补素材后重新写
记忆方案短期+工作记忆不需要跨会话记住用户,但单次任务需要记住中间状态
模型选择研究员/审核用强模型,撰写者用便宜模型搜索决策和审核需要推理能力,撰写是格式化工作

33.3 工具定义:给Agent的瑞士军刀

先定义Agent要用的4个工具。回顾第29章的核心原则:工具描述写得好,Agent就聪明;写得烂,Agent就犯蠢

import json
import requests
from bs4 import BeautifulSoup
from typing import Dict, List, Optional

# ============================================================
# 工具1:搜索引擎
# ============================================================
def search_web(query: str, num_results: int = 5) -> str:
    """
    搜索互联网获取相关网页列表。
    
    何时使用:当你需要查找某个主题的信息时。
    何时不用:当你已经有足够的资料时,不要重复搜索。
    
    参数:
        query: 搜索关键词,要具体明确,比如"Rust语言 2024 发展趋势"
        num_results: 返回结果数量,默认5条,最多10条
    
    返回:搜索结果列表(标题+URL+摘要),JSON格式
    """
    # 这里用SearXNG或Serper API做示例
    # 实际项目中可以用 DuckDuckGo、Bing API、SerpAPI 等
    try:
        # 示例:使用DuckDuckGo的HTML接口(免费,无需API Key)
        url = "https://html.duckduckgo.com/html/"
        headers = {"User-Agent": "Mozilla/5.0 (research-agent/1.0)"}
        resp = requests.post(url, data={"q": query}, headers=headers, timeout=10)
        soup = BeautifulSoup(resp.text, "html.parser")
        
        results = []
        for item in soup.select(".result")[:num_results]:
            title_tag = item.select_one(".result__title")
            snippet_tag = item.select_one(".result__snippet")
            link_tag = item.select_one(".result__url")
            
            if title_tag and link_tag:
                results.append({
                    "title": title_tag.get_text(strip=True),
                    "url": link_tag.get("href", ""),
                    "snippet": snippet_tag.get_text(strip=True) if snippet_tag else ""
                })
        
        return json.dumps({"success": True, "results": results}, ensure_ascii=False)
    except Exception as e:
        return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)


# ============================================================
# 工具2:获取网页内容
# ============================================================
def fetch_webpage(url: str) -> str:
    """
    获取指定URL网页的正文内容(去除导航、广告等无关内容)。
    
    何时使用:当你需要读取某个搜索结果的详细内容时。
    何时不用:当摘要已经包含足够信息时,不需要抓取全文。
    
    参数:
        url: 要抓取的网页URL
    
    返回:网页正文文本(最多5000字,太长会截断),包含标题
    """
    try:
        headers = {"User-Agent": "Mozilla/5.0 (research-agent/1.0)"}
        resp = requests.get(url, headers=headers, timeout=15)
        resp.encoding = resp.apparent_encoding or "utf-8"
        
        soup = BeautifulSoup(resp.text, "html.parser")
        
        # 移除无关标签
        for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
            tag.decompose()
        
        title = soup.find("title")
        title_text = title.get_text(strip=True) if title else "无标题"
        
        # 提取正文
        article = soup.find("article") or soup.find("main") or soup.find("body")
        if article:
            text = article.get_text(separator="\n", strip=True)
        else:
            text = soup.get_text(separator="\n", strip=True)
        
        # 清理多余空行
        lines = [line.strip() for line in text.split("\n") if line.strip()]
        text = "\n".join(lines)
        
        # 截断过长的内容
        max_chars = 5000
        if len(text) > max_chars:
            text = text[:max_chars] + "\n\n[... 内容过长,已截断 ...]"
        
        result = {
            "success": True,
            "title": title_text,
            "url": url,
            "content": text
        }
        return json.dumps(result, ensure_ascii=False)
    except Exception as e:
        return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)


# ============================================================
# 工具3:保存研究素材
# ============================================================
# 工作记忆:存储Agent收集到的所有素材
research_notes: List[Dict] = []

def save_note(source: str, content: str, key_points: str) -> str:
    """
    将一条研究素材保存到研究笔记中。
    
    何时使用:当你从网页中提取了有价值的信息时。
    何时不用:当网页内容与主题无关时,不要保存。
    
    参数:
        source: 信息来源URL或标题
        content: 原文相关段落(保留原文,不要改写)
        key_points: 你从这段内容中提取的关键要点(1-3条)
    
    返回:保存确认信息
    """
    note = {
        "id": len(research_notes) + 1,
        "source": source,
        "content": content,
        "key_points": key_points,
        "saved_at": f"素材 #{len(research_notes) + 1}"
    }
    research_notes.append(note)
    return json.dumps({
        "success": True,
        "message": f"已保存素材 #{note['id']},当前共 {len(research_notes)} 条素材",
        "total_notes": len(research_notes)
    }, ensure_ascii=False)


# ============================================================
# 工具4:查看已有素材
# ============================================================
def review_notes() -> str:
    """
    查看当前已收集的所有研究素材。
    
    何时使用:当你需要回顾已经收集了哪些信息,判断是否需要继续搜索时。
    何时不用:当你刚开始还没收集任何素材时。
    
    返回:所有素材的摘要列表
    """
    if not research_notes:
        return json.dumps({"success": True, "notes": [], "message": "目前还没有任何素材"}, ensure_ascii=False)
    
    summary = []
    for note in research_notes:
        summary.append({
            "id": note["id"],
            "source": note["source"],
            "key_points": note["key_points"]
        })
    return json.dumps({"success": True, "notes": summary, "total": len(summary)}, ensure_ascii=False)


# ============================================================
# 工具注册表(给Function Call用的格式)
# ============================================================
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "search_web",
            "description": "搜索互联网获取相关网页列表。当你需要查找某个主题的信息时使用。",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "搜索关键词,要具体明确,比如'Rust语言 2024 发展趋势'"
                    },
                    "num_results": {
                        "type": "integer",
                        "description": "返回结果数量,默认5条,最多10条",
                        "default": 5
                    }
                },
                "required": ["query"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "fetch_webpage",
            "description": "获取指定URL网页的正文内容。当你需要读取某个搜索结果的详细内容时使用。",
            "parameters": {
                "type": "object",
                "properties": {
                    "url": {
                        "type": "string",
                        "description": "要抓取的网页URL"
                    }
                },
                "required": ["url"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "save_note",
            "description": "将一条研究素材保存到研究笔记中。当你从网页中提取了有价值的信息时使用。",
            "parameters": {
                "type": "object",
                "properties": {
                    "source": {
                        "type": "string",
                        "description": "信息来源URL或标题"
                    },
                    "content": {
                        "type": "string",
                        "description": "原文相关段落,保留原文不要改写"
                    },
                    "key_points": {
                        "type": "string",
                        "description": "你从这段内容中提取的关键要点,1-3条"
                    }
                },
                "required": ["source", "content", "key_points"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "review_notes",
            "description": "查看当前已收集的所有研究素材摘要。当你需要判断是否收集了足够信息时使用。",
            "parameters": {
                "type": "object",
                "properties": {}
            }
        }
    }
]

# 工具名到函数的映射
TOOL_FUNCTIONS = {
    "search_web": search_web,
    "fetch_webpage": fetch_webpage,
    "save_note": save_note,
    "review_notes": review_notes,
}

大白话讲解工具设计思路:

  • search_web — Agent的"搜索引擎",关键词怎么搜由Agent决定
  • fetch_webpage — Agent的"浏览器",点开网页看详情
  • save_note — Agent的"笔记本",看完觉得有用就记下来,还要求提取关键要点(这个约束很重要,逼着Agent在保存时就做信息处理)
  • review_notes — Agent的"翻笔记本",回头看看收集了啥,决定够不够

33.4 研究员Agent:ReAct引擎实现

研究员Agent是整个系统的核心——它用ReAct模式自主决定搜索什么、抓取什么、保存什么。

import json
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")

# ============================================================
# 研究员Agent Prompt
# ============================================================
RESEARCHER_SYSTEM_PROMPT = """你是一个专业的研究员Agent。你的任务是对给定主题进行深入研究,收集高质量的研究素材。

## 工作流程(ReAct模式)

你必须按以下循环工作:
1. **Thought**: 分析当前状况,决定下一步做什么
2. **Action**: 调用工具执行操作
3. **Observation**: 查看工具返回的结果
4. 重复上述步骤,直到你认为素材足够

## 工作要求

1. **搜索策略**:先用宽泛关键词搜索了解概况,再用具体关键词深入挖掘
2. **信息筛选**:只保存与主题直接相关的信息,不要保存无关内容
3. **素材质量**:每条素材都要有清晰的key_points,不要原样照搬大段文字
4. **多角度收集**:不要只看一家之言,要收集不同观点和信息源
5. **适时停止**:当你已经有5-8条高质量素材,且覆盖了主题的主要方面时,可以停止搜索

## 输出格式

当你认为素材已经收集完毕,不要调用任何工具,直接输出:
[RESEARCH_COMPLETE]
研究素材收集完毕,共收集 N 条素材,涵盖以下方面:
- 方面1:xxx
- 方面2:xxx
- ...

## 注意事项

- 每次搜索后先看摘要,有价值再抓取全文,不要每个结果都抓
- 抓取网页后先判断是否相关,不相关就不保存
- 如果某个搜索方向没找到好内容,换个关键词再试
- 最多搜索10次,抓取15个网页,避免无限搜索
"""

# ============================================================
# ReAct引擎
# ============================================================
class ResearchAgent:
    """研究员Agent:基于ReAct模式的自主研究引擎"""
    
    def __init__(
        self,
        model: str = "qwen2-7b-instruct",
        tools: list = None,
        tool_functions: dict = None,
        system_prompt: str = "",
        max_steps: int = 20,
        temperature: float = 0.3,
    ):
        self.model = model
        self.tools = tools or []
        self.tool_functions = tool_functions or {}
        self.system_prompt = system_prompt
        self.max_steps = max_steps
        self.temperature = temperature
        self.messages = []
        self.execution_log = []  # 完整执行轨迹(审计日志)
        
    def _init_messages(self, user_input: str):
        """初始化对话"""
        self.messages = [
            {"role": "system", "content": self.system_prompt},
            {"role": "user", "content": f"请研究以下主题:\n\n{user_input}\n\n请开始搜索和收集素材。"}
        ]
    
    def _call_model(self) -> str:
        """调用LLM"""
        response = client.chat.completions.create(
            model=self.model,
            messages=self.messages,
            tools=self.tools if self.tools else None,
            tool_choice="auto" if self.tools else None,
            temperature=self.temperature,
            max_tokens=2000,
        )
        return response.choices[0].message
    
    def _execute_tool(self, tool_call) -> str:
        """执行工具调用"""
        func_name = tool_call.function.name
        func_args = json.loads(tool_call.function.arguments)
        
        # 记录执行日志
        log_entry = {
            "step": len(self.execution_log) + 1,
            "tool": func_name,
            "args": func_args,
        }
        
        # 执行函数
        if func_name in self.tool_functions:
            try:
                result = self.tool_functions[func_name](**func_args)
                log_entry["success"] = True
                log_entry["result_preview"] = result[:200]
            except Exception as e:
                result = json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
                log_entry["success"] = False
                log_entry["error"] = str(e)
        else:
            result = json.dumps({"success": False, "error": f"未知工具: {func_name}"}, ensure_ascii=False)
            log_entry["success"] = False
            log_entry["error"] = f"未知工具: {func_name}"
        
        self.execution_log.append(log_entry)
        return result
    
    def run(self, research_topic: str) -> dict:
        """执行研究任务"""
        self._init_messages(research_topic)
        
        print(f"\n{'='*60}")
        print(f"🔍 研究员Agent启动")
        print(f"📋 研究主题:{research_topic}")
        print(f"🤖 模型:{self.model}")
        print(f"⚡ 最大步数:{self.max_steps}")
        print(f"{'='*60}\n")
        
        for step in range(1, self.max_steps + 1):
            print(f"\n--- 第 {step} 步 ---")
            
            # 调用模型
            assistant_message = self._call_model()
            
            # 情况1:模型调用了工具
            if assistant_message.tool_calls:
                # 先保存模型的思考内容(如果有)
                if assistant_message.content:
                    print(f"💭 Thought: {assistant_message.content[:100]}...")
                    self.messages.append({
                        "role": "assistant",
                        "content": assistant_message.content,
                        "tool_calls": [
                            {
                                "id": tc.id,
                                "type": "function",
                                "function": {
                                    "name": tc.function.name,
                                    "arguments": tc.function.arguments
                                }
                            }
                            for tc in assistant_message.tool_calls
                        ]
                    })
                else:
                    self.messages.append({
                        "role": "assistant",
                        "content": None,
                        "tool_calls": [
                            {
                                "id": tc.id,
                                "type": "function",
                                "function": {
                                    "name": tc.function.name,
                                    "arguments": tc.function.arguments
                                }
                            }
                            for tc in assistant_message.tool_calls
                        ]
                    })
                
                # 执行每个工具调用
                for tool_call in assistant_message.tool_calls:
                    func_name = tool_call.function.name
                    func_args = json.loads(tool_call.function.arguments)
                    print(f"🔧 Action: {func_name}({json.dumps(func_args, ensure_ascii=False)[:100]})")
                    
                    result = self._execute_tool(tool_call)
                    print(f"📊 Observation: {result[:100]}...")
                    
                    # 把工具结果喂给模型
                    self.messages.append({
                        "role": "tool",
                        "tool_call_id": tool_call.id,
                        "content": result
                    })
            
            # 情况2:模型没调用工具,输出了文本(可能结束了)
            elif assistant_message.content:
                content = assistant_message.content
                self.messages.append({"role": "assistant", "content": content})
                
                if "[RESEARCH_COMPLETE]" in content:
                    print(f"✅ 研究完成!")
                    print(f"📝 {content}")
                    return {
                        "status": "completed",
                        "summary": content,
                        "steps": step,
                        "notes": research_notes,
                        "execution_log": self.execution_log
                    }
                else:
                    # 模型可能在思考但没调工具,继续推它
                    print(f"💭 模型输出(无工具调用): {content[:100]}...")
                    self.messages.append({
                        "role": "user",
                        "content": "请继续使用工具收集素材,或者如果素材已足够,请输出 [RESEARCH_COMPLETE] 标记。"
                    })
        
        # 超过最大步数
        print(f"⚠️ 达到最大步数 {self.max_steps},强制结束")
        return {
            "status": "max_steps_reached",
            "summary": f"达到最大步数 {self.max_steps},已收集 {len(research_notes)} 条素材",
            "steps": self.max_steps,
            "notes": research_notes,
            "execution_log": self.execution_log
        }

研究员执行示例

假设用户输入"Rust语言在2024年的发展",以下是Agent的执行轨迹:

=== 研究员Agent启动 ===
研究主题:Rust语言在2024年的发展

--- 第1步 ---
Thought: 我需要先搜索了解Rust在2024年的整体发展情况
Action: search_web(query="Rust语言 2024年发展")
Observation: 返回5条搜索结果,包括"Rust 2024年度报告"、"Rust进入Linux内核"等

--- 第2步 ---
Thought: "Rust 2024年度报告"这个结果看起来很有价值,让我抓取详细内容
Action: fetch_webpage(url="https://blog.rust-lang.org/2024/...")
Observation: 返回了网页正文,约3000字,介绍了Rust 2024的里程碑

--- 第3步 ---
Thought: 这篇文章提到了Rust进入Linux内核、性能提升等关键信息,值得保存
Action: save_note(source="Rust 2024年度报告", content="...", key_points="1. Rust正式进入Linux内核 2. 性能优化显著 3. 生态持续增长")
Observation: 已保存素材 #1,当前共1条素材

--- 第4步 ---
Thought: 我还需要搜索Rust在Web开发和企业应用方面的情况
Action: search_web(query="Rust Web开发 企业应用 2024")
Observation: 返回5条结果,包括"Rust在企业中的采用率"、"WebAssembly与Rust"等

--- 第5步 ---
Thought: 让我抓取"Rust在企业中的采用率"这篇文章
Action: fetch_webpage(url="https://...")
Observation: 返回网页正文,提到越来越多大公司采用Rust

--- 第6步 ---
Thought: 企业采用趋势很有价值,保存这条素材
Action: save_note(source="...", content="...", key_points="1. 微信/字节等大厂采用Rust 2. 人才市场增长 3. 从C++迁移趋势")
Observation: 已保存素材 #2

...(继续搜索2-3轮,收集6-8条素材后)...

--- 第12步 ---
Thought: 我已经收集了7条素材,覆盖了Rust 2024的语言特性、内核集成、企业采用、Web开发、社区增长等方面。素材足够了。
输出: [RESEARCH_COMPLETE]
研究素材收集完毕,共收集 7 条素材,涵盖以下方面:
- 语言特性:Rust 2024 Edition新特性
- 系统级应用:Rust进入Linux内核
- 企业采用:大公司采用趋势和人才市场
- Web开发:WebAssembly与Rust
- 社区生态:包下载量和开发者增长

33.5 撰写者Agent:结构化报告生成

研究员收集完素材后,把素材交给撰写者,生成结构化报告。

# ============================================================
# 撰写者Agent
# ============================================================
WRITER_SYSTEM_PROMPT = """你是一个专业的研究报告撰写者。你的任务是根据提供的研究素材,撰写一份结构清晰、内容翔实的研究报告。

## 报告结构

请严格按照以下结构撰写报告:

### 1. 概述(1-2段)
简要介绍研究主题的背景和本文要讨论的内容。

### 2. 现状分析(2-3段)
基于素材,分析该主题当前的发展状况。

### 3. 关键发现(3-5个要点)
列出最重要的发现,每个要点要有论据支撑。

### 4. 趋势展望(1-2段)
基于现有信息,分析未来可能的发展方向。

### 5. 总结(1段)
简短总结全文,给出核心结论。

## 写作要求

1. **引用标注**:每条关键信息后用 [1]、[2] 等标注来源,对应素材编号
2. **客观中立**:基于素材分析,不要添加素材中没有的主观判断
3. **信息整合**:多个素材提到同一信息时,合并表述,不要重复
4. **语言简洁**:用专业但通俗的语言,避免堆砌术语
5. **数据支撑**:有具体数据的优先使用,比"增长很快"更有说服力

## 输出格式

直接输出Markdown格式的报告正文,不要加额外的说明。
"""

def generate_report(research_topic: str, notes: list) -> str:
    """撰写者Agent:根据素材生成结构化报告"""
    
    # 把素材格式化给模型看
    notes_text = ""
    for note in notes:
        notes_text += f"""
--- 素材 [{note['id']}] ---
来源:{note['source']}
关键要点:{note['key_points']}
原文摘录:{note['content'][:500]}
"""
    
    user_prompt = f"""
## 研究主题
{research_topic}

## 研究素材(共 {len(notes)} 条)
{notes_text}

请根据以上素材撰写研究报告。记住:
- 每条关键信息后标注来源 [素材编号]
- 如果素材中有矛盾的信息,如实呈现不同观点
- 不要编造素材中没有的信息
"""
    
    response = client.chat.completions.create(
        model="qwen2-7b-instruct",
        messages=[
            {"role": "system", "content": WRITER_SYSTEM_PROMPT},
            {"role": "user", "content": user_prompt}
        ],
        temperature=0.5,
        max_tokens=3000,
    )
    
    return response.choices[0].message.content

大白话: 撰写者的活就是"看笔记写报告"。关键设计是引用标注——每条信息都要标明来自哪条素材,这样用户能追溯信息来源,也防止模型瞎编。


33.6 审核员Agent:质量自检

写完报告不等于完事,还得有个"质检员"检查质量。

# ============================================================
# 审核员Agent
# ============================================================
REVIEWER_SYSTEM_PROMPT = """你是一个严格的研究报告审核员。你需要检查报告的质量,确保报告准确、完整、有据可依。

## 检查维度

1. **引用完整性**:报告中提到的关键信息是否都有素材引用?
2. **信息准确性**:报告中的信息是否与素材一致?有没有歪曲或过度解读?
3. **内容完整性**:是否覆盖了素材中的主要发现?有没有遗漏重要信息?
4. **结构规范性**:是否符合概述→现状→发现→趋势→总结的结构?
5. **语言质量**:表述是否清晰?有没有自相矛盾的地方?

## 输出格式

请输出JSON格式的审核结果:

```json
{
    "overall_score": 1-10的评分,
    "pass": true/false,
    "issues": [
        {
            "type": "引用缺失|信息不准|内容遗漏|结构问题|语言问题",
            "description": "具体问题描述",
            "severity": "high|medium|low",
            "suggestion": "修改建议"
        }
    ],
    "summary": "审核总结"
}

判定标准

  • score >= 8 且无 high severity 问题:pass = true
  • 其他情况:pass = false

注意

  • 要严格对照素材检查,不要凭主观判断
  • 如果报告中有素材里没有的数据或结论,标记为"信息不准"
  • 引用标注缺失是 high severity 问题
    “”"

def review_report(report: str, notes: list) -> dict:
“”“审核员Agent:检查报告质量”“”

notes_text = ""
for note in notes:
    notes_text += f"""

— 素材 [{note[‘id’]}] —
来源:{note[‘source’]}
关键要点:{note[‘key_points’]}
“”"

user_prompt = f"""

待审核报告

{report}

研究素材(共 {len(notes)} 条)

{notes_text}

请对照素材审核这份报告。
“”"

response = client.chat.completions.create(
    model="qwen2-7b-instruct",
    messages=[
        {"role": "system", "content": REVIEWER_SYSTEM_PROMPT},
        {"role": "user", "content": user_prompt}
    ],
    temperature=0.1,  # 审核要确定性,温度调低
    max_tokens=1500,
)

result_text = response.choices[0].message.content

# 尝试解析JSON
try:
    # 去掉可能的Markdown代码块标记
    if "```json" in result_text:
        result_text = result_text.split("```json")[1].split("```")[0]
    elif "```" in result_text:
        result_text = result_text.split("```")[1].split("```")[0]
    
    return json.loads(result_text.strip())
except json.JSONDecodeError:
    # JSON解析失败,返回默认结果
    return {
        "overall_score": 5,
        "pass": False,
        "issues": [{"type": "格式问题", "description": "审核结果JSON解析失败", "severity": "high"}],
        "summary": "审核结果格式异常,需要重新审核"
    }

**大白话:** 审核员干的活就是"拿报告跟笔记对一对"——你的报告说"Rust下载量增长50%",那我得看看素材里是不是真的这么写的。没有引用就是"空口白话",不通过。温度调到0.1是因为审核要严格一致,不需要"创造性"。

---

## 33.7 完整系统串联

现在把三个Agent串成一条流水线:

```python
# ============================================================
# 完整研究报告Agent系统
# ============================================================
class ResearchReportSystem:
    """完整的研究报告生成系统:研究员→撰写者→审核员"""
    
    def __init__(self, model: str = "qwen2-7b-instruct"):
        self.model = model
        self.research_log = []
        self.review_history = []
        self.max_revision_rounds = 2  # 最多打回修改2次
    
    def run(self, research_topic: str) -> dict:
        """执行完整的研究报告生成流程"""
        global research_notes
        research_notes = []  # 重置素材
        
        print(f"\n{'='*60}")
        print(f"🚀 研究报告生成系统启动")
        print(f"📋 研究主题:{research_topic}")
        print(f"{'='*60}")
        
        # 阶段1:研究员收集素材
        print(f"\n📡 阶段1/3:研究员收集素材")
        print(f"{'-'*40}")
        
        researcher = ResearchAgent(
            model=self.model,
            tools=TOOLS,
            tool_functions=TOOL_FUNCTIONS,
            system_prompt=RESEARCHER_SYSTEM_PROMPT,
            max_steps=20,
            temperature=0.3,
        )
        
        research_result = researcher.run(research_topic)
        self.research_log = research_result.get("execution_log", [])
        
        notes = research_notes.copy()
        print(f"\n✅ 研究阶段完成:收集了 {len(notes)} 条素材")
        
        if len(notes) == 0:
            return {
                "status": "failed",
                "error": "研究员未能收集到任何素材",
                "report": None
            }
        
        # 阶段2:撰写者生成报告
        print(f"\n✍️ 阶段2/3:撰写者生成报告")
        print(f"{'-'*40}")
        
        report = generate_report(research_topic, notes)
        print(f"✅ 报告初稿生成完毕({len(report)} 字)")
        
        # 阶段3:审核员检查质量
        print(f"\n🔍 阶段3/3:审核员检查质量")
        print(f"{'-'*40}")
        
        for round_num in range(1, self.max_revision_rounds + 1):
            print(f"\n--- 第 {round_num} 轮审核 ---")
            
            review_result = review_report(report, notes)
            self.review_history.append(review_result)
            
            score = review_result.get("overall_score", 0)
            passed = review_result.get("pass", False)
            issues = review_result.get("issues", [])
            
            print(f"📊 评分:{score}/10")
            print(f"{'✅ 通过' if passed else '❌ 未通过'}")
            
            if issues:
                print(f"📋 发现 {len(issues)} 个问题:")
                for issue in issues:
                    severity = issue.get("severity", "unknown")
                    emoji = "🔴" if severity == "high" else "🟡" if severity == "medium" else "🟢"
                    print(f"  {emoji} [{issue.get('type')}] {issue.get('description')}")
            
            if passed:
                print(f"\n🎉 审核通过!")
                break
            
            if round_num < self.max_revision_rounds:
                print(f"\n🔄 需要修改,重新生成报告...")
                # 把审核意见给撰写者,让它修改
                report = self._revise_report(report, notes, review_result, research_topic)
                print(f"✅ 报告修改完成({len(report)} 字)")
            else:
                print(f"\n⚠️ 达到最大修改次数,使用当前版本")
        
        # 生成最终输出
        final_report = self._format_final_report(
            research_topic, report, notes, research_result
        )
        
        return {
            "status": "completed",
            "report": final_report,
            "research_summary": research_result.get("summary", ""),
            "notes_count": len(notes),
            "research_steps": research_result.get("steps", 0),
            "review_rounds": len(self.review_history),
            "final_score": self.review_history[-1].get("overall_score", 0) if self.review_history else 0,
            "execution_log": self.research_log,
        }
    
    def _revise_report(self, report: str, notes: list, review: dict, topic: str) -> str:
        """根据审核意见修改报告"""
        issues_text = ""
        for issue in review.get("issues", []):
            issues_text += f"- [{issue.get('severity')}] {issue.get('type')}: {issue.get('description')} → 建议: {issue.get('suggestion')}\n"
        
        revise_prompt = f"""请根据以下审核意见修改研究报告:

## 审核发现的问题
{issues_text}

## 当前报告
{report}

## 研究素材
{json.dumps([{'id': n['id'], 'source': n['source'], 'key_points': n['key_points']} for n in notes], ensure_ascii=False, indent=2)}

请修改报告解决上述问题。保持原有的结构(概述→现状→发现→趋势→总结),确保每条关键信息都有引用标注。
"""
        
        response = client.chat.completions.create(
            model=self.model,
            messages=[
                {"role": "system", "content": WRITER_SYSTEM_PROMPT},
                {"role": "user", "content": revise_prompt}
            ],
            temperature=0.4,
            max_tokens=3000,
        )
        
        return response.choices[0].message.content
    
    def _format_final_report(self, topic, report, notes, research_result):
        """格式化最终报告(加上元信息)"""
        
        header = f"""# 研究报告:{topic}

> **生成时间**:{__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M')}
> **素材数量**:{len(notes)} 条
> **搜索步数**:{research_result.get('steps', 0)} 步
> **审核轮次**:{len(self.review_history)} 轮
> **最终评分**:{self.review_history[-1].get('overall_score', '?')}/10

---

"""
        
        # 报告正文
        body = report
        
        # 附录:素材来源
        footer = f"""

---

## 附录:素材来源

"""
        for note in notes:
            footer += f"- [{note['id']}] {note['source']}\n"
        
        return header + body + footer


# ============================================================
# 运行系统
# ============================================================
if __name__ == "__main__":
    system = ResearchReportSystem(model="qwen2-7b-instruct")
    
    result = system.run("Rust语言在2024年的发展")
    
    print(f"\n{'='*60}")
    print(f"📊 执行统计")
    print(f"{'='*60}")
    print(f"素材数量:{result['notes_count']} 条")
    print(f"搜索步数:{result['research_steps']} 步")
    print(f"审核轮次:{result['review_rounds']} 轮")
    print(f"最终评分:{result['final_score']}/10")
    print(f"\n{'='*60}")
    print(f"📄 最终报告")
    print(f"{'='*60}\n")
    print(result["report"])

33.8 执行轨迹分析

用"Rust语言在2024年的发展"这个主题跑一遍,完整执行轨迹如下:

研究员阶段(约12-15步)

步骤动作结果
1search_web(“Rust语言 2024年发展”)5条搜索结果
2fetch_webpage(年度报告URL)获取3000字网页内容
3save_note(年度报告, 关键要点)保存素材#1
4search_web(“Rust 企业采用 2024”)5条新搜索结果
5fetch_webpage(企业采用URL)获取2000字内容
6save_note(企业采用, 关键要点)保存素材#2
7review_notes()查看2条素材,判断不够
8search_web(“Rust WebAssembly 2024”)5条搜索结果
9fetch_webpage(WebAssembly URL)获取2500字内容
10save_note(WebAssembly, 关键要点)保存素材#3
11search_web(“Rust 社区 增长 2024”)5条搜索结果
12fetch_webpage(社区增长URL)获取1800字内容
13save_note(社区增长, 关键要点)保存素材#4
14review_notes()查看4条素材,判断足够
15输出 [RESEARCH_COMPLETE]研究阶段结束

撰写者阶段

根据4条素材,生成约2000字的Markdown格式研究报告,包含概述、现状、关键发现、趋势展望、总结五个部分,每条信息都标注了[1][2][3][4]引用。

审核员阶段

轮次评分结果主要问题
第1轮7/10未通过2个medium问题:趋势部分缺引用;1个high问题:遗漏素材#4的社区数据
第2轮9/10通过无high问题,1个low问题:某处表述可以更精确

最终输出

一份包含元信息头、报告正文、素材来源附录的完整Markdown报告。


33.9 成本估算

以Qwen2-7B本地部署为例,跑一个完整研究任务:

环节Token消耗估算费用
研究员(15步,每步约2000 Token)~30,000 Token自部署≈0元
撰写者(1次,约4000 Token)~4,000 Token自部署≈0元
审核员(2轮,每轮约2000 Token)~4,000 Token自部署≈0元
撰写者修改(1次,约4000 Token)~4,000 Token自部署≈0元
合计~42,000 Token自部署≈电费

如果用API(以DeepSeek API为例,输入¥0.5/百万Token,输出¥2/百万Token):

项目计算费用
输入Token(约30,000)30,000 × 0.5/1M¥0.015
输出Token(约12,000)12,000 × 2/1M¥0.024
合计约¥0.04/次

大白话: 本地部署一分钱不花(就电费),用API一次才4分钱。做100次研究报告才4块钱。这个成本对任何项目来说都不是问题。


33.10 工程化改进建议

上面的代码是可运行的核心实现,但从"能跑的Demo"到"能上线的系统"还有几道坎:

1. 搜索引擎优化

当前用的DuckDuckGo免费接口,质量一般。生产环境建议:

方案优点缺点成本
SerpAPI结果质量高,支持Google收费$50/月5000次
Bing Search API微软官方,质量不错收费按量计费
SearXNG自建免费且隐私需要自己部署服务器成本
Tavily API专为AI设计收费按量计费

2. 并行优化

研究员Agent当前是串行搜索的(搜一次→抓一次→存一次),可以优化为:

def parallel_search_and_fetch(self, queries: list):
    """并行搜索多个关键词"""
    from concurrent.futures import ThreadPoolExecutor, as_completed
    
    with ThreadPoolExecutor(max_workers=3) as executor:
        # 并行发起搜索
        future_to_query = {
            executor.submit(search_web, q): q for q in queries
        }
        
        results = {}
        for future in as_completed(future_to_query):
            query = future_to_query[future]
            try:
                results[query] = future.result()
            except Exception as e:
                results[query] = {"error": str(e)}
    
    return results

但注意:并行搜索要谨慎,Agent的ReAct循环本身就是探索性的——先搜一个方向看看结果再决定下一个搜索方向。盲目并行可能浪费API调用。

3. 缓存

同样的搜索查询不要重复搜:

from functools import lru_cache

@lru_cache(maxsize=100)
def search_web_cached(query: str, num_results: int = 5) -> str:
    return search_web(query, num_results)

网页内容也缓存——同一个URL抓过一次就别再抓了。

4. 超时和降级

# 研究员超时处理
import signal

class TimeoutError(Exception):
    pass

def timeout_handler(signum, frame):
    raise TimeoutError("Agent执行超时")

# 设置120秒超时
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(120)  # 120秒

try:
    result = researcher.run(topic)
except TimeoutError:
    # 降级:用已有素材生成报告
    print("⚠️ 研究超时,使用已有素材生成报告")
    result = {"notes": research_notes, "status": "timeout"}
finally:
    signal.alarm(0)  # 取消闹钟

5. 监控和可观测性

每次运行的完整轨迹都应该记录下来:

def log_execution(self, topic: str, result: dict):
    """记录执行日志"""
    log = {
        "timestamp": datetime.now().isoformat(),
        "topic": topic,
        "status": result["status"],
        "notes_count": result["notes_count"],
        "research_steps": result["research_steps"],
        "review_rounds": result["review_rounds"],
        "final_score": result["final_score"],
        "execution_log": result["execution_log"],
        # 可以加更多维度
    }
    
    # 存到数据库或文件
    with open(f"logs/{datetime.now().strftime('%Y%m%d_%H%M%S')}_research.json", "w") as f:
        json.dump(log, f, ensure_ascii=False, indent=2)

6. 流式输出

用户不想等3分钟才看到结果,流式输出让用户实时看到Agent在干什么:

# 在ReAct引擎中加入流式输出
def _call_model_stream(self):
    """流式调用LLM"""
    stream = client.chat.completions.create(
        model=self.model,
        messages=self.messages,
        tools=self.tools,
        tool_choice="auto",
        temperature=self.temperature,
        stream=True,  # 开启流式
    )
    
    # 实时输出Thought
    for chunk in stream:
        if chunk.choices[0].delta.content:
            print(chunk.choices[0].delta.content, end="", flush=True)
        # ...

7. Web界面

用FastAPI包一层,提供API接口:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio

app = FastAPI(title="研究Agent API")

@app.post("/research")
async def research(topic: str):
    """同步研究接口"""
    system = ResearchReportSystem()
    result = system.run(topic)
    return {
        "status": result["status"],
        "report": result["report"],
        "stats": {
            "notes_count": result["notes_count"],
            "steps": result["research_steps"],
            "review_rounds": result["review_rounds"],
            "score": result["final_score"]
        }
    }

@app.post("/research/stream")
async def research_stream(topic: str):
    """流式研究接口(SSE)"""
    async def event_stream():
        system = ResearchReportSystem()
        
        # 在每个阶段发送事件
        yield f"data: {json.dumps({'type': 'stage', 'stage': 'researching'})}\n\n"
        # ... 研究员执行 ...
        yield f"data: {json.dumps({'type': 'notes', 'count': len(notes)})}\n\n"
        
        yield f"data: {json.dumps({'type': 'stage', 'stage': 'writing'})}\n\n"
        # ... 撰写者执行 ...
        
        yield f"data: {json.dumps({'type': 'stage', 'stage': 'reviewing'})}\n\n"
        # ... 审核员执行 ...
        
        yield f"data: {json.dumps({'type': 'complete', 'report': final_report})}\n\n"
    
    return StreamingResponse(event_stream(), media_type="text/event-stream")

33.11 常见问题排查

问题1:研究员搜索跑飞了,不停搜索

原因:Agent判断不了"什么时候素材够了"

解决方案

  1. 在Prompt中明确停止条件:“当你已经有5-8条高质量素材时停止”
  2. 设置max_steps=20硬性限制
  3. 每步检查素材数量,超过10条自动终止
# 在run方法中加检查
if len(research_notes) >= 10:
    print("⚠️ 素材已达上限,强制结束研究")
    break

问题2:抓取的网页内容是乱码

原因:编码问题,有些网页用了非标准编码

解决方案

resp.encoding = resp.apparent_encoding or "utf-8"
# apparent_encoding会自动检测编码

问题3:报告引用编号跟素材对不上

原因:撰写者有时候会编造引用编号

解决方案

  1. 在撰写者Prompt中强调:“只能引用[1]到[{len(notes)}]的素材,不要编造编号”
  2. 审核员检查时验证每个引用编号是否在范围内
  3. 可以在后处理阶段用正则检查:
import re

def validate_citations(report: str, notes_count: int) -> list:
    """验证报告中的引用编号是否合法"""
    # 找出所有引用编号
    citations = re.findall(r'\[(\d+)\]', report)
    invalid = [int(c) for c in citations if int(c) > notes_count or int(c) < 1]
    return invalid

问题4:审核总是不通过

原因:审核标准太严格,或模型评分不一致

解决方案

  1. 调整审核标准:score >= 7 就通过
  2. 限制最多修改2轮,超过就用当前版本
  3. 审核员温度调到0.1,提高一致性

问题5:生成的报告太短/太长

原因:max_tokens设置不合理

解决方案

报告部分建议Token数
概述200-300
现状分析500-800
关键发现800-1200
趋势展望300-500
总结150-200
合计2000-3000

在撰写者Prompt中也可以加字数提示:“报告总长度约2000-3000字”。


33.12 扩展方向

这个Agent系统是一个起步框架,可以往很多方向扩展:

1. 多模态研究

不只是搜文字,还能搜图片和视频:

def search_images(query: str) -> str:
    """搜索图片"""
    # 调用图片搜索API
    pass

def analyze_image(image_url: str) -> str:
    """用多模态模型分析图片内容"""
    # 调用GPT-4V或Qwen-VL
    pass

2. 实时数据

接入实时数据源(股价、天气、新闻):

def get_stock_data(symbol: str) -> str:
    """获取股票实时数据"""
    pass

def get_latest_news(topic: str) -> str:
    """获取最新新闻"""
    pass

3. 知识库结合

把RAG(第5阶段学的)作为Agent的一个工具,让它能查企业内部知识库:

def search_knowledge_base(query: str) -> str:
    """搜索企业知识库(RAG检索)"""
    # 调用第21-27章的RAG系统
    # 向量检索 + 重排序 + 上下文组装
    pass

4. 多语言研究

搜索不同语言的资料,最后用中文输出报告:

# 研究员用英文搜索英文资料(信息更全)
# 撰写者用中文写报告(用户看中文)
researcher = ResearchAgent(system_prompt=ENGLISH_RESEARCH_PROMPT)
writer = WriterAgent(system_prompt=CHINESE_WRITER_PROMPT)

5. 人机协作

在关键节点让用户确认:

# 研究员搜索完,列出找到的素材,让用户确认
print("找到了以下素材:")
for note in notes:
    print(f"  [{note['id']}] {note['key_points'][:50]}...")
user_confirm = input("是否继续生成报告?(y/n): ")
if user_confirm.lower() != 'y':
    # 让用户补充搜索方向
    additional_topic = input("请补充搜索方向:")
    researcher.run(additional_topic)

6. 定时任务

每天/每周自动生成研究报告:

import schedule
import time

def auto_research():
    topics = ["AI行业周报", "Rust语言月度发展", "区块链技术动态"]
    for topic in topics:
        system = ResearchReportSystem()
        result = system.run(topic)
        # 存到数据库或发邮件
        save_report(result)

# 每周一早上8点执行
schedule.every().monday.at("08:00").do(auto_research)

while True:
    schedule.run_pending()
    time.sleep(60)

33.13 本章总结

系统架构回顾

组件职责用到的章节知识
研究员Agent自主搜索→抓取→提取→保存素材ReAct模式(第30章)+ Function Call(第29章)
撰写者Agent根据素材生成结构化报告Prompt工程(第17章)
审核员Agent检查引用/准确性/完整性LLM-as-Judge(第20章)
工作记忆research_notes列表记忆机制(第32章)
流水线编排研究员→撰写者→审核员工作流编排(第31章)

核心设计决策

  1. 用流水线而非多Agent辩论 — 研究任务有明确阶段,流水线最简单
  2. 研究员内部用ReAct — 搜索是探索性的,需要循环判断
  3. 审核不通过打回研究员 — 不是撰写者重写,而是补素材后重写
  4. 引用标注是硬约束 — 每条信息必须标明来源,防止幻觉
  5. 限制最大步数和修改轮次 — 防止无限循环

从Demo到生产的改进清单

维度Demo版(本章代码)生产版需要做的
搜索引擎DuckDuckGo免费接SerpAPI/Bing/Tavily
并行处理串行搜索并行搜索+并行抓取
缓存搜索缓存+网页缓存
超时降级简单超时多级超时+降级策略
监控print日志结构化日志+指标监控
用户界面命令行Web界面+流式输出
持久化内存数据库存储
部署脚本运行Docker+API服务

最终感悟

Agent系统不是"大模型+工具"的简单组合,而是一个完整的工程系统。

你看这章的代码,核心逻辑其实不复杂——ReAct循环、Function Call、流水线编排,都是前面学过的东西。但真正把它跑起来,需要处理的细节很多:

  • 工具描述怎么写模型才听话
  • Prompt怎么设计输出才规范
  • 搜索跑了飞怎么约束
  • 报告引用怎么验证
  • 审核不通过怎么修改
  • 成本怎么控制
  • 超时怎么处理

这些工程细节才是Agent系统从"能跑"到"好用"的关键。

跟第9章讲ML项目时的道理一样:80%的时间在处理数据和边界情况,20%在调模型。Agent系统也是——核心算法不难,工程落地才是大头。


第6阶段「Agent智能体」完整回顾

章节核心知识
第28章 Agent概念四大组件、五大模式、vs RAG关系
第29章 Function Call工具定义、四大模型对比、错误处理
第30章 ReAct模式Think-Act-Observe循环、手写引擎、五种模式对比
第31章 工作流编排四种协作模式、LangGraph/CrewAI/AutoGen
第32章 记忆机制短期/工作/长期、五种遗忘策略
第33章 实战案例完整研究报告Agent系统(搜索→写→审)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

(轻舟已过万重山)

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值