Appearance
练手项目 · Research Agent
这是第三阶段的综合练习,也是整个主线课程的最后一个项目。
前两个项目分别让你体验了"单轮对话"(AI 聊天助手)和"检索增强"(RAG 知识库)。这个项目要把它们推进到"多步自主执行":用 Agent 完成一项调研任务,并在过程中真实面对安全、幻觉和终止控制的问题。
目标不是做"万能智能体",而是做一个只读、安全、可追踪的研究助手。可控比强大更重要。
建议前置章节
先确认你已经读过:
- Tool Calling — Agent 的基础能力
- Agent 基础原理 — Loop 控制、状态管理、终止条件
- AI 幻觉 — 多步执行时幻觉如何积累
- Prompt Injection 与 AI 安全 — 读取外部内容的风险
- AI 应用评测 — 怎么验证 Agent 行为符合预期
项目目标
用户输入一个研究主题,系统:
- 把主题拆成若干搜索子问题
- 依次执行搜索,读取关键内容
- 提取、整合信息
- 输出结构化摘要、引用来源和待确认项
整个过程必须:可观测(每步有日志)、有边界(有步数上限)、只读(不触发任何写操作)。
这个项目真正要验证的,不是“Agent 会自己跑”
如果前两个项目分别在验证"模型怎么说话"和"证据怎么进来",那这个项目验证的就是:当系统开始自己分步做事时,你还能不能把它管住。
也就是说,这个项目真正要面对的是几类更现实的问题:
- 它会不会把任务拆偏
- 它会不会在某一步拿到脏信息后一路带偏后续步骤
- 它会不会在该停的时候不停
- 它会不会在不知道时硬往下猜
- 你出了问题之后,到底能不能看清它是怎么错的
所以这不是在做"更高级的聊天机器人",而是在做第一个真正会动起来、也会失控的系统。
第一版必须完成的能力
这是最低可交付标准:
- 接收研究主题:用户输入一句话,Agent 开始工作
- 任务拆解:把主题拆成 2-4 个具体搜索子问题
- 执行搜索:对每个子问题执行搜索,读取若干结果
- 结构化摘要:整合信息,输出可读的结论摘要
- 列出引用来源:每条结论说明依据来源
- 列出待确认项:对不确定的部分标注"以下内容仍需核实"
- 步数上限:系统在达到最大步数时,安全停止并输出当前结果
这六项是第一版验收的最低要求。不需要 UI 很漂亮,不需要支持复杂主题,但以上六点缺一不可。
这个版本切法很有意图。第一版故意把范围收在"只读研究助手",不是为了保守,而是为了让你先把最关键的风险变量压住:
- 只读,避免把 Agent 风险直接升级成真实副作用
- 有日志,保证一出问题还能查
- 有上限,防止它无限跑
- 有待确认项,防止它把推断伪装成事实
推荐工具范围
第一版只接只读工具,不要接任何写操作:
| 工具 | 用途 |
|---|---|
| 网页搜索 | 根据子问题查找相关页面 |
| 读取网页正文 | 获取页面实际内容 |
| 时间查询 | 确认当前日期,避免时效性错误 |
| 本地资料读取(可选) | 读取本地已有文档 |
先不要接"发邮件""写文件""更新数据库"这类工具。写操作会引入更高的安全风险,等第一版跑稳了再考虑扩展。
这是一个很值得建立的工程判断:Agent 能做的事情越多,不代表越接近好产品。对第三阶段练手来说,最重要的是先把控制能力建立起来,而不是追求能力面。
第二版建议补充
第一版跑通后,可以逐步增加:
- 展示执行步骤:前端实时显示 Agent 正在做什么
- 展示每次工具调用结果:让用户看到搜索到了什么
- 失败重试策略:工具调用失败后自动重试,但有最大重试次数
- 区分事实和推断:对结论里的推断部分主动标注"以下为推测,非直接引用"
- 可配置步数上限:让用户或配置文件控制最大步数
工程要求
这些要求不是可选的,它们直接连接 Agent 到评测这几章的内容:
终止控制(对应 Agent 基础原理):
- 必须设步数上限,并可配置
- 达到步数上限时,输出当前已有结论,而不是静默失败
- 连续工具调用失败超过一定次数时,主动中断
防幻觉设计(对应 AI 幻觉):
- 所有结论必须有对应来源,不能纯靠模型推断
- 对搜索结果没有覆盖的问题,输出"资料不足,无法确认"而不是发挥
- 中间结果必须记录,便于复查每一步的推断依据
安全要求(对应 Prompt Injection 与 AI 安全):
- 工具默认只读,不暴露任何写操作
- 搜索结果和网页内容不能直接触发新的工具调用,必须经过模型判断
- 保留完整工具调用日志,包括调用了什么、传了什么参数、返回了什么
评测要求(对应 AI 应用评测):
- 准备至少 5 个测试主题,覆盖"正常调研"和"刻意刁难"两类
- 有一条专门测试"故意在搜索结果里藏注入指令",验证系统不被影响
- 有一条专门测试"搜索结果不足",验证系统能正确表达不确定性
这些工程要求放在一起,其实是在强迫你接受一个事实:Agent 的正确性不是一个单点指标,而是多个边界同时成立的结果。
最终报告看起来还行远远不够。过程受控、信息有依据、遇到不确定时不装懂、外部内容不能轻易接管控制流、出了问题以后能复盘——这几条必须同时成立。
最低验收标准
做完第一版,用以下标准验收:
- 用户输入主题后,Agent 能完成至少 2-3 步工具调用
- 最终输出包含摘要、引用来源和待确认项
- 步数达到上限时,系统安全停止并输出当前结果(不崩溃、不无响应)
- 没有高风险写操作
- 工具调用日志完整(能看到每一步调了什么)
- 搜索结果里如果含有类似"忽略以上指令"的内容,系统不被影响
建议输出物
做完项目后,可以整理以下文档,作为第三阶段的学习产出:
- 可运行的 Research Agent(页面或 CLI 均可)
- 《Research Agent 设计文档》:记录你的 Agent Loop 设计、状态结构、工具列表和终止策略
- 最小评测样例集:至少 5 条,覆盖上面提到的测试类型
- 日志格式说明:记录你选择记录哪些字段,以及为什么
骨架代码参考
这份骨架用 mock 搜索工具实现一个最小 Research Agent。它的重点不是搜索结果有多真实,而是让你看清 Agent Loop、工具调用、轨迹记录和终止控制怎么连在一起。
目录结构
text
research-agent/
├── config.py
├── tools.py
├── tracer.py
├── agent.py
└── main.pyconfig.py
python
MODEL = "gpt-4o-mini"
MAX_STEPS = 6
SYSTEM_PROMPT = """
你是一个只读 Research Agent。
你可以调用工具收集信息,但不能编造来源。
最终报告必须包含:summary、sources、unknowns 三部分。
如果资料不足,把不确定内容放进 unknowns。
"""tools.py
python
from datetime import datetime
TOOLS = [
{
"type": "function",
"function": {
"name": "web_search",
"description": "搜索研究问题,返回若干只读摘要结果",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索关键词"}
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "current_time",
"description": "获取当前日期时间,处理时效性问题",
"parameters": {"type": "object", "properties": {}},
},
},
]
def web_search(query: str) -> str:
# 第一版用 mock,先把 Agent Loop 调通,再换真实搜索 API。
return (
f"搜索:{query}\n"
"1. 示例来源 A:RAG 系统需要同时评估检索质量和生成质量。\n"
"2. 示例来源 B:Agent 系统要设置最大步数和工具调用日志。\n"
"3. 示例来源 C:没有来源支撑的结论应放入待确认项。"
)
def current_time() -> str:
return datetime.now().isoformat(timespec="seconds")
def execute_tool(name: str, args: dict) -> str:
if name == "web_search":
return web_search(args["query"])
if name == "current_time":
return current_time()
return f"未知工具:{name}"tracer.py
python
import json
from datetime import datetime
class Tracer:
def __init__(self):
self.events: list[dict] = []
def log_step(
self,
step: int,
action: str,
tool_name: str | None = None,
args: dict | None = None,
result: str | None = None,
) -> None:
entry = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"step": step,
"action": action,
"tool": tool_name,
"args": args,
"result_preview": result[:200] if result else None,
}
self.events.append(entry)
print(json.dumps(entry, ensure_ascii=False))这和前面的 log_step 片段保持一致,只是封装成了类,方便最后把完整轨迹留在内存里。
agent.py
python
import json
from openai import OpenAI
from config import MAX_STEPS, MODEL, SYSTEM_PROMPT
from tools import TOOLS, execute_tool
from tracer import Tracer
client = OpenAI()
def final_report(messages: list[dict], reason: str) -> str:
response = client.chat.completions.create(
model=MODEL,
messages=messages
+ [
{
"role": "user",
"content": (
f"{reason}\n"
"请输出最终报告,使用 Markdown,包含 summary、sources、unknowns。"
),
}
],
)
return response.choices[0].message.content or ""
def run_agent(task: str, tracer: Tracer) -> str:
messages: list[dict] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": task},
]
for step in range(1, MAX_STEPS + 1):
tracer.log_step(step, "plan")
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
)
message = response.choices[0].message
messages.append(message.model_dump(exclude_none=True))
if not message.tool_calls:
tracer.log_step(step, "finish", result=message.content or "")
return message.content or ""
for tool_call in message.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments or "{}")
tracer.log_step(step, "tool_call", name, args)
result = execute_tool(name, args)
tracer.log_step(step, "observe", name, args, result)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
}
)
tracer.log_step(MAX_STEPS, "finish", result="达到最大步数")
return final_report(messages, "已达到最大步数,请基于现有信息安全收束。")这里的 MAX_STEPS 是保险丝。到达上限后,系统不会静默退出,而是要求模型基于已有工具结果输出当前结论,并把不确定的部分放到 unknowns。
main.py
python
from agent import run_agent
from tracer import Tracer
def main() -> None:
task = input("请输入研究主题:").strip()
tracer = Tracer()
report = run_agent(task, tracer)
print("\n=== 最终报告 ===")
print(report)
if __name__ == "__main__":
main()运行方式
安装依赖:
bash
pip install openai设置环境变量:
bash
export OPENAI_API_KEY="你的 API Key"
# 使用兼容接口时再设置:
# export OPENAI_BASE_URL="https://your-provider.example.com/v1"启动:
bash
python main.py可以先输入:
text
RAG 系统为什么不能只看最终回答质量?你应该能看到类似这样的轨迹:
text
{"step":1,"action":"plan","tool":null}
{"step":1,"action":"tool_call","tool":"web_search","args":{"query":"RAG 系统 最终回答质量 检索质量"}}
{"step":1,"action":"observe","tool":"web_search","result_preview":"搜索:RAG 系统..."}最终报告至少应该有摘要、来源和待确认项。即使 mock 搜索结果很粗糙,也不要让模型把没有来源的判断写成确定事实。
如何切换到真实搜索工具
保留 TOOLS 的 JSON Schema 不变,把 web_search() 的函数体换成真实搜索 API 调用即可,比如 Tavily、SerpAPI 或你自己的搜索服务。替换时先限制返回字段,只保留标题、URL、摘要和发布时间。不要把整页 HTML 原样塞回模型。
这份骨架的局限
第一版没有真实搜索、没有网页正文读取,也没有完整 Prompt Injection 防护。它只证明一件事:Agent Loop 可以被宿主程序控制,工具调用可以被记录,达到步数上限时可以安全收束。等这条链路稳定,再接真实搜索和网页读取会稳得多。
轨迹分析:怎么看 Agent 到底在做什么
Agent 出问题时,最常见的反应是去改 Prompt。但 Prompt 不是唯一的问题源,也往往不是最重要的问题源。正确的做法是先分析轨迹——把 Agent 执行的每一步打印出来,逐步看。
一条完整的轨迹应该包含:
步骤 1: 规划 → 拆解任务为 [子问题1, 子问题2, 子问题3]
步骤 2: 调用 web_search("子问题1") → 返回 3 条结果
步骤 3: 调用 read_page("url_1") → 读取正文 800 字
步骤 4: 规划 → 判断子问题1已有足够信息,转向子问题2
步骤 5: 调用 web_search("子问题2") → 返回 5 条结果
...
步骤 N: 生成摘要,列出引用来源看轨迹时重点关注:
- 有没有重复调用同一工具(可能是状态记录有问题)
- 子问题拆得合不合理(太宽的子问题搜出来一堆无关内容)
- 某一步的工具返回结果是否真的被下一步使用(还是模型"看了但没用")
- 有没有在中间某步就偏离了原始任务
轨迹分析是 Agent 调试的核心工具,比盲改 Prompt 有效得多。
为什么轨迹比结果更重要
这是做 Agent 项目时最该尽早建立的认知。
最终输出当然重要,但对 Agent 来说,很多最危险的问题恰恰不会直接写在最终答案里。它们藏在中间过程:
- 某一步误把推断当事实
- 某次工具调用其实没拿到有用结果
- 某个子问题明明已经完成,却还在继续搜
- 某次网页内容里出现了可疑注入,但系统没有识别
如果你只看最终结果,很可能会错过这些过程性风险。轨迹的价值,就是把"它到底怎么走到这里"暴露出来。
日志格式建议:
python
import json
from datetime import datetime
def log_step(step: int, action: str, tool_name: str = None,
args: dict = None, result: str = None):
entry = {
"timestamp": datetime.now().isoformat(),
"step": step,
"action": action, # "plan" / "tool_call" / "observe" / "finish"
"tool": tool_name,
"args": args,
"result_preview": result[:200] if result else None # 只记前 200 字
}
print(json.dumps(entry, ensure_ascii=False))
# 实际场景里写到文件或数据库记录 result_preview 而不是完整结果,是因为网页正文可能几千字,全量写日志会导致日志文件飞速膨胀。
你会真正学到什么
做完这个项目,你应该能真实体会到:
- Agent Loop 怎么落地:把"Plan → Act → Observe"从抽象概念变成可运行代码
- 状态管理为什么重要:没有状态时你会发现 Agent 不断重复,或者忘记自己做过什么
- 终止条件不设会怎样:你会真实看到它在某个任务上"跑飞"
- 外部内容带来的 Prompt Injection 风险:当你用真实搜索结果时,会遇到各种奇怪的网页内容
- 评测需要多早:改了一个 Prompt 后,你会想跑一遍测试集确认没有退化
为什么这个项目是整条主线的综合考试
前面的章节到这里基本都会汇合:
- Tool Calling 决定它怎么接工具
- Agent Loop 决定它怎么分步推进
- 幻觉决定它会怎样把小错滚成大错
- 安全决定外部内容能不能污染控制流
- 评测决定你能不能知道它在慢慢变坏
所以这个项目的难点,从来都不是"把搜索和读网页串起来",而是把前面那些分散的工程原则,第一次装进同一个活系统里。
复盘问题
项目做完后,思考以下问题,也可以写成学习笔记:
- 你的 Agent 是真的在做任务拆解,还是只是在多次独立聊天?
- 哪一步最容易失控:规划、工具选择还是总结?
- 如果搜索结果之间互相矛盾,你的系统会怎么处理?
- 你设的步数上限是多少?这个数字是怎么决定的?
- 哪些测试用例暴露了你没想到的问题?
和主线章节的连接
| 工程要求 | 对应章节 |
|---|---|
| Agent Loop、状态、终止控制 | Agent 基础原理 |
| 防幻觉设计、不确定性标注 | AI 幻觉 |
| 只读工具、注入防护、日志 | Prompt Injection 与 AI 安全 |
| 测试样例、回归测试 | AI 应用评测 |
| 模块划分、编排层、失败路径 | AI 应用系统设计 |
回头看:几条值得记住的教训
第一版最重要的是过程可见、边界守得住。搜得聪不聪明是后面的事。
只读策略看起来像是在限制能力,实际上是在帮你先把 Agent 风险降到可控范围。没有轨迹的 Agent 项目几乎无法有效调试,因为错误往往藏在中间步骤,最终输出里看不出来。
“待确认项”这个功能容易被轻视,但它做的事情很实在:把不确定性正式暴露给用户,让用户自己判断。
做完这个项目之后,很多人会意识到一件事——真正拉开差距的,往往不是谁的智能体更花哨,而是谁更早理解了系统设计、安全和评测为什么比“模型更强”更重要。