Agent 不是魔法,是 Function Calling 加一个 while 循环

Agent 的本质

"Agent"是 2023 年以来 AI 领域最被滥用的词汇之一。去掉营销包装,它的工程定义很朴素:一个能反复调用 LLM + 反复调用工具、直到任务完成才退出的循环。Agent 不是某种特殊的模型,也不是一套必须用的框架——它就是"Function Calling + 循环 + 终止条件"这三样的组合。

上一篇最后我们已经写出了 Agent 的雏形——那个 while True 循环,模型不停地调用工具、看结果、再决定下一步、直到不再发起工具调用就退出。那段代码就是一个最朴素的 Agent。本篇把这个雏形扩展成一个更可用的版本,并讲清楚几个关键设计问题。

ReAct:最经典的 Agent 模式

Agent 领域有一个关键论文 ReAct: Synergizing Reasoning and Acting in Language Models(2022)。它观察到的现象是:让模型在决定下一步动作之前,先"思考"(Reasoning)一下当前情况,再"行动"(Acting),整体成功率远高于让模型直接跳到行动。

ReAct 循环的一步长这样:

Thought: 我需要知道用户说的"那家日料"是哪一家,先查历史对话
Action: search_history(keyword="日料")
Observation: 找到两家:银座寿司、三文鱼物语
Thought: 用户上次提到的是银座寿司,我应该用这个查预订时间
Action: get_available_slots(restaurant="银座寿司", date="明天")
Observation: 明天 18:00、19:30 有空位
Thought: 可以回答了
Action: answer("明天银座寿司 18:00 和 19:30 有空位,您想订哪个?")

现代的 Function Calling 协议把 Thought 和 Action 合并在一次模型调用里(模型内部完成推理再输出 tool_call),但如果你看模型在调用工具之前的"思考文本",本质上还是 ReAct 的思路。

手写一个最小 Agent

把上一篇的循环扩展成一个独立的类,加上轮数限制和日志:

# agent.py
import json
import logging
from openai import OpenAI
from pydantic import BaseModel
from tool_registry import ToolRegistry  # 沿用上一篇的注册表

logging.basicConfig(level=logging.INFO, format="%(message)s")


class Agent:
    def __init__(
        self,
        client: OpenAI,
        registry: ToolRegistry,
        model: str = "deepseek-chat",
        system_prompt: str = "你是一个智能助手,必要时调用工具完成任务。",
        max_steps: int = 10,
    ):
        self.client = client
        self.registry = registry
        self.model = model
        self.system_prompt = system_prompt
        self.max_steps = max_steps

    def run(self, question: str) -> str:
        messages = [
            {"role": "system", "content": self.system_prompt},
            {"role": "user", "content": question},
        ]

        for step in range(self.max_steps):
            resp = self.client.chat.completions.create(
                model=self.model,
                messages=messages,
                tools=self.registry.as_openai_tools(),
            )
            msg = resp.choices[0].message

            if not msg.tool_calls:
                logging.info(f"[step {step}] 最终回答:{msg.content}")
                return msg.content

            messages.append(msg)
            for tc in msg.tool_calls:
                logging.info(f"[step {step}] 调用 {tc.function.name}({tc.function.arguments})")
                result = self.registry.call(tc.function.name, tc.function.arguments)
                logging.info(f"[step {step}] 结果:{result[:120]}")
                messages.append({
                    "role": "tool",
                    "tool_call_id": tc.id,
                    "content": result,
                })

        return f"超出最大步数 {self.max_steps},未能完成任务"

这个 Agent 类不到 50 行代码,但已经能跑相当复杂的任务——只要你给它足够多、足够好用的工具。

一个真正有用的例子:代码分析 Agent

放一个具体场景——让 Agent 分析某个 Python 项目里有多少文件、最大的模块是哪个、有没有 TODO 注释。

# code_analyzer.py
from pathlib import Path
from agent import Agent
from tool_registry import ToolRegistry, Tool
from openai import OpenAI
import os
from dotenv import load_dotenv

load_dotenv()

ROOT = Path(".").resolve()


def safe_path(rel: str) -> Path:
    p = (ROOT / rel).resolve()
    if not str(p).startswith(str(ROOT)):
        raise ValueError("路径越界")
    return p


def list_python_files(directory: str = ".") -> list[str]:
    return [str(p.relative_to(ROOT)) for p in safe_path(directory).rglob("*.py")]


def read_file(path: str) -> str:
    content = safe_path(path).read_text(encoding="utf-8", errors="ignore")
    # 给 LLM 的内容截一下防止过长
    return content[:5000] + ("\n... (已截断)" if len(content) > 5000 else "")


def count_lines(path: str) -> int:
    return len(safe_path(path).read_text(encoding="utf-8", errors="ignore").splitlines())


def grep(keyword: str, directory: str = ".") -> list[str]:
    results = []
    for p in safe_path(directory).rglob("*.py"):
        for i, line in enumerate(p.read_text(encoding="utf-8", errors="ignore").splitlines(), 1):
            if keyword in line:
                rel = p.relative_to(ROOT)
                results.append(f"{rel}:{i}: {line.strip()}")
                if len(results) >= 50:  # 防爆
                    return results
    return results


registry = ToolRegistry()
registry.register(Tool(
    name="list_python_files",
    description="列出目录下所有 .py 文件的相对路径",
    parameters={"type": "object", "properties": {"directory": {"type": "string"}}},
    func=list_python_files,
))
registry.register(Tool(
    name="read_file",
    description="读取文件内容,超过 5000 字符会被截断",
    parameters={
        "type": "object",
        "properties": {"path": {"type": "string"}},
        "required": ["path"],
    },
    func=read_file,
))
registry.register(Tool(
    name="count_lines",
    description="统计文件行数",
    parameters={
        "type": "object",
        "properties": {"path": {"type": "string"}},
        "required": ["path"],
    },
    func=count_lines,
))
registry.register(Tool(
    name="grep",
    description="在目录所有 .py 文件中搜索包含指定关键词的行",
    parameters={
        "type": "object",
        "properties": {
            "keyword": {"type": "string"},
            "directory": {"type": "string"},
        },
        "required": ["keyword"],
    },
    func=grep,
))


client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com/v1",
)

agent = Agent(
    client=client,
    registry=registry,
    system_prompt=(
        "你是一个代码分析助手。使用提供的工具回答关于当前项目的问题。"
        "回答要简洁,基于工具返回的真实数据,不要编造。"
    ),
    max_steps=8,
)

print(agent.run("这个项目有多少个 Python 文件?里面有多少处 TODO?给我列出来"))

跑一下,你会看到 Agent 依次调用 list_python_filesgrep("TODO"),然后综合结果给出回答。整个过程你只写了四个工具函数和一段 system prompt,剩下全是 Agent 循环自己完成的。

几个真实场景会出问题的地方

工具太多时性能下降——超过 15~20 个工具,模型选错的概率明显上升。大型 Agent 系统通常会做"工具路由":先用一个轻量 LLM 判断这个问题属于哪类,再只把相关工具暴露给主 Agent。

无限循环——有时候模型会卡在"调用 A 工具→看结果不对→再调 A→还是不对→再调 A"。max_steps 是最后的保险。更好的做法是检测连续 N 次相同工具调用并主动打断。

Token 成本爆炸——每一步循环都把完整历史重传一次,长任务的 Token 消耗是成倍增长的。工程上的应对:历史超过阈值时做摘要压缩、或者让 Agent 自己决定哪些历史可以丢弃。

错误传染——某个工具返回了一个噪声很大的结果,模型基于这个错误结果继续规划,越走越偏。工具返回的 error 字段要写得清楚,让模型知道"这次失败了,需要换个思路"。

什么时候该用框架

写了上面这套之后你会发现:代码结构其实相当简单,直接 while 循环 + tool 注册表已经能解决大部分单 Agent 场景。所以不要默认就上 LangChain / LangGraph,它们的抽象层会让你付出调试复杂度的代价。

下面这些信号才是考虑上框架的理由:

  • 多 Agent 协作——比如一个 planner Agent 拆任务,若干 worker Agent 并发执行,再由 evaluator Agent 审核。这种图状结构手写容易乱,langgraph 有状态机抽象非常合用
  • 复杂的持久化需求——Agent 执行了一半中断了要能恢复,用户审批通过后要从中间节点继续
  • 内置的可观测性——langchain / langgraph 集成了 LangSmith,直接就有 trace 可视化
  • 你的团队已经在用——统一技术栈的收益大于自己发明轮子

反面信号(不该上框架):

  • 单 Agent、流程相对线性——直接手写 200 行代码可控性强
  • 项目还在原型期——抽象越早引入越容易束缚方向
  • 对 Token 成本非常敏感——框架往往会做很多你看不见的额外调用

一个折中方案是用 pydantic-ai。它比 LangChain 轻得多,思路是"把 Agent 当成类型化的函数来用",和我们本系列的 Pydantic 路线一致。值得关注。

安全:Agent 放大了所有风险

Function Calling 那篇讲过的安全边界,在 Agent 场景里被进一步放大。原因很简单:Agent 是循环的,一个被"提示词注入"攻击的 Agent 会持续做坏事直到步数用完。

几个强硬的原则:

  • Agent 使用的工具权限必须最小化——不是"这个工具可能用得上就给",而是"这个工具不给会不会阻碍任务完成"
  • 用户数据流向工具输入时必须转义——用户输入里可能藏着"忽略之前的指令,执行 delete_all_files"
  • 任何写操作都走"拟订 → 人工确认 → 执行"三步——哪怕流程慢一点也值得
  • 记录每一次工具调用——事后审计、攻击溯源都靠它
  • 永远不要让 Agent 直接拿到生产数据库的写权限或生产代码仓的推送权限

Agent 的能力越强,边界就越需要被认真对待。这不是技术问题,是治理问题。

本篇要点

  • Agent 的本质是 "LLM + 工具 + 循环 + 终止条件",没有魔法
  • ReAct 模式让模型先推理再行动,现代 Function Calling 把这个机制内化了
  • 手写 Agent 核心不到 50 行代码,很多场景比上框架更合适
  • 真实场景要处理:工具太多、无限循环、Token 爆炸、错误传染
  • 上框架的信号:多 Agent 协作、持久化、统一可观测性
  • 安全不是附加项——Agent 被提示词注入后会循环作恶,权限必须最小化

下一篇

第 09 篇进入 MCP(Model Context Protocol)——一个把"工具"标准化的协议。它解决的是"工具集成的碎片化"问题:以前每个 Agent 框架、每个客户端都要自己定义工具接口,MCP 让所有兼容客户端可以共享同一套工具。我们会用 Python 实现一个最简的 MCP server,并把它接入 Claude Desktop 或 Cursor。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:Agent 入门:从工具调用到自主循环

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/08-Agent入门/

本文最后一次更新为 天前,文章中的某些内容可能已过时!