第33章 实战案例:从零搭建一个完整的Agent系统
大白话: 前面学了这么多零件——Function Call是手、ReAct是脑子、工作流编排是团队协作、记忆是笔记本。现在该组装一辆能跑的车了。
33.1 项目背景:我们要做什么
需求描述
产品经理提了个需求:「做一个智能研究员Agent,给它一个研究主题,它能自动搜索资料、整理信息、写出一份结构化研究报告」。
听起来简单,但拆开来看,这个Agent需要:
- 理解任务 — 用户说"帮我研究一下Rust语言在2024年的发展",Agent得知道要搜什么
- 搜索资料 — 调搜索引擎API找相关文章
- 获取网页内容 — 搜索结果只是标题和摘要,得把网页内容抓下来
- 信息提取 — 从一堆网页内容里提取跟主题相关的关键信息
- 去重整合 — 多篇文章可能有重复信息,得合并去重
- 结构化输出 — 不是把信息堆上去就行,得有逻辑结构(背景/现状/趋势/结论)
- 质量自检 — 写完报告自己审一遍,发现不足的地方补搜
- 引用标注 — 每个观点标明出处,不能空口白话
为什么这个项目有代表性
| 考察点 | 本章涉及的章节 |
|---|---|
| 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步)
| 步骤 | 动作 | 结果 |
|---|---|---|
| 1 | search_web(“Rust语言 2024年发展”) | 5条搜索结果 |
| 2 | fetch_webpage(年度报告URL) | 获取3000字网页内容 |
| 3 | save_note(年度报告, 关键要点) | 保存素材#1 |
| 4 | search_web(“Rust 企业采用 2024”) | 5条新搜索结果 |
| 5 | fetch_webpage(企业采用URL) | 获取2000字内容 |
| 6 | save_note(企业采用, 关键要点) | 保存素材#2 |
| 7 | review_notes() | 查看2条素材,判断不够 |
| 8 | search_web(“Rust WebAssembly 2024”) | 5条搜索结果 |
| 9 | fetch_webpage(WebAssembly URL) | 获取2500字内容 |
| 10 | save_note(WebAssembly, 关键要点) | 保存素材#3 |
| 11 | search_web(“Rust 社区 增长 2024”) | 5条搜索结果 |
| 12 | fetch_webpage(社区增长URL) | 获取1800字内容 |
| 13 | save_note(社区增长, 关键要点) | 保存素材#4 |
| 14 | review_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判断不了"什么时候素材够了"
解决方案:
- 在Prompt中明确停止条件:“当你已经有5-8条高质量素材时停止”
- 设置max_steps=20硬性限制
- 每步检查素材数量,超过10条自动终止
# 在run方法中加检查
if len(research_notes) >= 10:
print("⚠️ 素材已达上限,强制结束研究")
break
问题2:抓取的网页内容是乱码
原因:编码问题,有些网页用了非标准编码
解决方案:
resp.encoding = resp.apparent_encoding or "utf-8"
# apparent_encoding会自动检测编码
问题3:报告引用编号跟素材对不上
原因:撰写者有时候会编造引用编号
解决方案:
- 在撰写者Prompt中强调:“只能引用[1]到[{len(notes)}]的素材,不要编造编号”
- 审核员检查时验证每个引用编号是否在范围内
- 可以在后处理阶段用正则检查:
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:审核总是不通过
原因:审核标准太严格,或模型评分不一致
解决方案:
- 调整审核标准:score >= 7 就通过
- 限制最多修改2轮,超过就用当前版本
- 审核员温度调到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章) |
核心设计决策
- 用流水线而非多Agent辩论 — 研究任务有明确阶段,流水线最简单
- 研究员内部用ReAct — 搜索是探索性的,需要循环判断
- 审核不通过打回研究员 — 不是撰写者重写,而是补素材后重写
- 引用标注是硬约束 — 每条信息必须标明来源,防止幻觉
- 限制最大步数和修改轮次 — 防止无限循环
从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系统(搜索→写→审) |


被折叠的 条评论
为什么被折叠?



