Skip to content

练手项目 · Research Agent

这是第三阶段的综合练习,也是整个主线课程的最后一个项目。

前两个项目分别让你体验了"单轮对话"(AI 聊天助手)和"检索增强"(RAG 知识库)。这个项目要把它们推进到"多步自主执行":用 Agent 完成一项调研任务,并在过程中真实面对安全、幻觉和终止控制的问题。

目标不是做"万能智能体",而是做一个只读、安全、可追踪的研究助手。可控比强大更重要。

建议前置章节

先确认你已经读过:

项目目标

用户输入一个研究主题,系统:

  1. 把主题拆成若干搜索子问题
  2. 依次执行搜索,读取关键内容
  3. 提取、整合信息
  4. 输出结构化摘要、引用来源和待确认项

整个过程必须:可观测(每步有日志)、有边界(有步数上限)、只读(不触发任何写操作)。

这个项目真正要验证的,不是“Agent 会自己跑”

如果前两个项目分别在验证"模型怎么说话"和"证据怎么进来",那这个项目验证的就是:当系统开始自己分步做事时,你还能不能把它管住。

也就是说,这个项目真正要面对的是几类更现实的问题:

  • 它会不会把任务拆偏
  • 它会不会在某一步拿到脏信息后一路带偏后续步骤
  • 它会不会在该停的时候不停
  • 它会不会在不知道时硬往下猜
  • 你出了问题之后,到底能不能看清它是怎么错的

所以这不是在做"更高级的聊天机器人",而是在做第一个真正会动起来、也会失控的系统。

第一版必须完成的能力

这是最低可交付标准:

  • 接收研究主题:用户输入一句话,Agent 开始工作
  • 任务拆解:把主题拆成 2-4 个具体搜索子问题
  • 执行搜索:对每个子问题执行搜索,读取若干结果
  • 结构化摘要:整合信息,输出可读的结论摘要
  • 列出引用来源:每条结论说明依据来源
  • 列出待确认项:对不确定的部分标注"以下内容仍需核实"
  • 步数上限:系统在达到最大步数时,安全停止并输出当前结果

这六项是第一版验收的最低要求。不需要 UI 很漂亮,不需要支持复杂主题,但以上六点缺一不可。

这个版本切法很有意图。第一版故意把范围收在"只读研究助手",不是为了保守,而是为了让你先把最关键的风险变量压住:

  • 只读,避免把 Agent 风险直接升级成真实副作用
  • 有日志,保证一出问题还能查
  • 有上限,防止它无限跑
  • 有待确认项,防止它把推断伪装成事实

推荐工具范围

第一版只接只读工具,不要接任何写操作:

工具用途
网页搜索根据子问题查找相关页面
读取网页正文获取页面实际内容
时间查询确认当前日期,避免时效性错误
本地资料读取(可选)读取本地已有文档

先不要接"发邮件""写文件""更新数据库"这类工具。写操作会引入更高的安全风险,等第一版跑稳了再考虑扩展。

这是一个很值得建立的工程判断:Agent 能做的事情越多,不代表越接近好产品。对第三阶段练手来说,最重要的是先把控制能力建立起来,而不是追求能力面。

第二版建议补充

第一版跑通后,可以逐步增加:

  • 展示执行步骤:前端实时显示 Agent 正在做什么
  • 展示每次工具调用结果:让用户看到搜索到了什么
  • 失败重试策略:工具调用失败后自动重试,但有最大重试次数
  • 区分事实和推断:对结论里的推断部分主动标注"以下为推测,非直接引用"
  • 可配置步数上限:让用户或配置文件控制最大步数

工程要求

这些要求不是可选的,它们直接连接 Agent 到评测这几章的内容:

终止控制(对应 Agent 基础原理)

  • 必须设步数上限,并可配置
  • 达到步数上限时,输出当前已有结论,而不是静默失败
  • 连续工具调用失败超过一定次数时,主动中断

防幻觉设计(对应 AI 幻觉)

  • 所有结论必须有对应来源,不能纯靠模型推断
  • 对搜索结果没有覆盖的问题,输出"资料不足,无法确认"而不是发挥
  • 中间结果必须记录,便于复查每一步的推断依据

安全要求(对应 Prompt Injection 与 AI 安全)

  • 工具默认只读,不暴露任何写操作
  • 搜索结果和网页内容不能直接触发新的工具调用,必须经过模型判断
  • 保留完整工具调用日志,包括调用了什么、传了什么参数、返回了什么

评测要求(对应 AI 应用评测)

  • 准备至少 5 个测试主题,覆盖"正常调研"和"刻意刁难"两类
  • 有一条专门测试"故意在搜索结果里藏注入指令",验证系统不被影响
  • 有一条专门测试"搜索结果不足",验证系统能正确表达不确定性

这些工程要求放在一起,其实是在强迫你接受一个事实:Agent 的正确性不是一个单点指标,而是多个边界同时成立的结果。

最终报告看起来还行远远不够。过程受控、信息有依据、遇到不确定时不装懂、外部内容不能轻易接管控制流、出了问题以后能复盘——这几条必须同时成立。

最低验收标准

做完第一版,用以下标准验收:

  1. 用户输入主题后,Agent 能完成至少 2-3 步工具调用
  2. 最终输出包含摘要、引用来源和待确认项
  3. 步数达到上限时,系统安全停止并输出当前结果(不崩溃、不无响应)
  4. 没有高风险写操作
  5. 工具调用日志完整(能看到每一步调了什么)
  6. 搜索结果里如果含有类似"忽略以上指令"的内容,系统不被影响

建议输出物

做完项目后,可以整理以下文档,作为第三阶段的学习产出:

  • 可运行的 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.py

config.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 决定它怎么分步推进
  • 幻觉决定它会怎样把小错滚成大错
  • 安全决定外部内容能不能污染控制流
  • 评测决定你能不能知道它在慢慢变坏

所以这个项目的难点,从来都不是"把搜索和读网页串起来",而是把前面那些分散的工程原则,第一次装进同一个活系统里。

复盘问题

项目做完后,思考以下问题,也可以写成学习笔记:

  1. 你的 Agent 是真的在做任务拆解,还是只是在多次独立聊天?
  2. 哪一步最容易失控:规划、工具选择还是总结?
  3. 如果搜索结果之间互相矛盾,你的系统会怎么处理?
  4. 你设的步数上限是多少?这个数字是怎么决定的?
  5. 哪些测试用例暴露了你没想到的问题?

和主线章节的连接

工程要求对应章节
Agent Loop、状态、终止控制Agent 基础原理
防幻觉设计、不确定性标注AI 幻觉
只读工具、注入防护、日志Prompt Injection 与 AI 安全
测试样例、回归测试AI 应用评测
模块划分、编排层、失败路径AI 应用系统设计

回头看:几条值得记住的教训

第一版最重要的是过程可见、边界守得住。搜得聪不聪明是后面的事。

只读策略看起来像是在限制能力,实际上是在帮你先把 Agent 风险降到可控范围。没有轨迹的 Agent 项目几乎无法有效调试,因为错误往往藏在中间步骤,最终输出里看不出来。

“待确认项”这个功能容易被轻视,但它做的事情很实在:把不确定性正式暴露给用户,让用户自己判断。

做完这个项目之后,很多人会意识到一件事——真正拉开差距的,往往不是谁的智能体更花哨,而是谁更早理解了系统设计、安全和评测为什么比“模型更强”更重要。

面向开发者系统学习 AI 应用开发、RAG、Agent 与 Vibe Coding。