最小Agent实现学习笔记

一、最小可运行 Agent 代码

代码基于文章中的 Agent Loop 设计,使用 OpenAI SDK 的 Function Calling 机制,约 120 行,可直接运行(只需填入你的 API Key 和 Base URL)。

python

import json, time, signal, os, ast, operator
from openai import OpenAI

# ============================================================
# 配置
# ============================================================
API_KEY = os.getenv("OPENAI_API_KEY")          # 替换为你的 Key
BASE_URL = "https://maas.qianwenaiapi.com/compatible-mode/v1"  # 可替换为兼容 OpenAI 的端点
MODEL = "qwen3.8-flash"             # 或任何支持 function calling 的模型
ALLOWED_ROOT = "/tmp/agent_sandbox"  # read_file 的允许根目录
MAX_STEPS = 8
TIMEOUT = 60                       # 总超时(秒)
TOOL_TIMEOUT = 5                   # 单次工具超时(秒)

client = OpenAI(api_key=API_KEY, base_url=BASE_URL)

# ============================================================
# 1. 工具执行函数
# ============================================================
def search(query: str) -> str:
    """模拟搜索。实际可接入真实搜索 API。"""
    return f"[模拟搜索结果] 关于「{query}」的摘要信息……"

# 安全计算器:白名单运算符递归求值,不使用 eval
_SAFE_OPS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
    ast.Pow: operator.pow,
    ast.USub: operator.neg,
}

def _eval_node(node):
    if isinstance(node, ast.Expression):
        return _eval_node(node.body)
    if isinstance(node, ast.Constant):
        return node.value
    if isinstance(node, ast.BinOp):
        op_type = type(node.op)
        if op_type not in _SAFE_OPS:
            raise ValueError(f"不允许的运算符:{op_type.__name__}")
        return _SAFE_OPS[op_type](_eval_node(node.left), _eval_node(node.right))
    if isinstance(node, ast.UnaryOp):
        op_type = type(node.op)
        if op_type not in _SAFE_OPS:
            raise ValueError(f"不允许的一元运算符:{op_type.__name__}")
        return _SAFE_OPS[op_type](_eval_node(node.operand))
    raise ValueError(f"不允许的表达式节点:{type(node).__name__}")

def calculator(expression: str) -> str:
    """安全计算四则运算表达式,如 \"(3 + 5) * 2\"。"""
    try:
        tree = ast.parse(expression, mode="eval")
        result = _eval_node(tree)
        return str(result)
    except Exception as e:
        return f"计算失败:{e}"

def read_file(file_path: str) -> str:
    """读取沙箱目录内的文本文件。"""
    abs_path = os.path.abspath(file_path)
    if not abs_path.startswith(os.path.abspath(ALLOWED_ROOT)):
        return "错误:路径不在允许范围内"
    try:
        with open(abs_path, "r", encoding="utf-8") as f:
            return f.read()
    except Exception as e:
        return f"读取失败:{e}"

TOOL_FUNCTIONS = {
    "search": search,
    "calculator": calculator,
    "read_file": read_file,
}

# ============================================================
# 2. 工具 Schema(LLM 看到的部分)
# ============================================================
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "search",
            "description": "搜索互联网信息。输入搜索关键词,返回结果摘要。",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "搜索关键词"}
                },
                "required": ["query"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "calculator",
            "description": "计算数学表达式。输入一个合法的数学表达式字符串,返回计算结果。",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {"type": "string", "description": "数学表达式,如 (3+5)*2"}
                },
                "required": ["expression"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": f"读取沙箱目录({ALLOWED_ROOT})内的文件内容。",
            "parameters": {
                "type": "object",
                "properties": {
                    "file_path": {"type": "string", "description": "文件的绝对路径或相对于沙箱根目录的路径"}
                },
                "required": ["file_path"],
            },
        },
    },
]

# ============================================================
# 3. 工具超时保护(Unix 主线程有效)
# ============================================================
class ToolTimeoutError(Exception):
    pass

def _alarm_handler(signum, frame):
    raise ToolTimeoutError("工具执行超时")

def run_with_timeout(func, args, timeout):
    old = signal.signal(signal.SIGALRM, _alarm_handler)
    signal.alarm(timeout)
    try:
        return func(**args)
    finally:
        signal.alarm(0)
        signal.signal(signal.SIGALRM, old)

# ============================================================
# 4. Agent Loop 核心
# ============================================================
def run_agent(user_input: str) -> str:
    messages = [{"role": "user", "content": user_input}]
    start = time.time()

    for step in range(MAX_STEPS):
        # 总超时检查
        if time.time() - start > TIMEOUT:
            return f"[兜底] 总超时({TIMEOUT}s),已执行 {step} 步。"

        # ---- Think: 调 LLM ----
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=TOOLS,
            tool_choice="auto",
        )
        msg = response.choices[0].message

        # 无工具调用 → 直接返回文本,结束
        if not msg.tool_calls:
            return msg.content or "[空回复]"

        # ---- 有工具调用:先把 assistant 消息加入历史(必须 model_dump) ----
        messages.append(msg.model_dump())

        # ---- Act: 逐个执行工具 ----
        for tool_call in msg.tool_calls:
            tool_name = tool_call.function.name
            try:
                tool_args = json.loads(tool_call.function.arguments)
            except json.JSONDecodeError as e:
                observation = json.dumps({"success": False, "error": f"参数 JSON 解析失败:{e}"})
            else:
                # 未知工具处理
                if tool_name not in TOOL_FUNCTIONS:
                    observation = json.dumps({"success": False, "error": f"未知工具:{tool_name}"})
                else:
                    # 执行 + 双层超时保护
                    try:
                        result = run_with_timeout(
                            TOOL_FUNCTIONS[tool_name], tool_args, TOOL_TIMEOUT
                        )
                        observation = json.dumps({"success": True, "result": result}, ensure_ascii=False)
                    except ToolTimeoutError:
                        observation = json.dumps({"success": False, "error": f"工具 {tool_name} 执行超时({TOOL_TIMEOUT}s)"})
                    except Exception as e:
                        observation = json.dumps({"success": False, "error": f"工具执行失败:{e}"}, ensure_ascii=False)

            # ---- Observe: 喂回工具结果(必须带 tool_call_id) ----
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": observation,
            })

        # 循环回到 Think,LLM 看到工具结果后决定下一步

    return f"[兜底] 达到最大步数({MAX_STEPS}),未能完成。"


# ============================================================
# 5. 运行
# ============================================================
if __name__ == "__main__":
    os.makedirs(ALLOWED_ROOT, exist_ok=True)
    with open(os.path.join(ALLOWED_ROOT, "note.txt"), "w") as f:
        f.write("这是沙箱内的示例文件内容。")

    # 测试用例:需要多步工具调用
    print(run_agent("先帮我计算 (15 + 27) * 3 是多少,然后用 search 查一下 'Python Agent Loop' 是什么。"))

运行前准备:pip install openai,填入有效的 API_KEY 和 BASE_URL。如果不在 Unix 主线程运行,signal.alarm 会报错,可去掉工具级超时或改用 concurrent.futures 实现。

二、学习笔记

1. Agent 循环的本质:一个带状态转移的 for 循环

文章把 Agent Loop 概括为 Think → Act → Observe 的循环。用最朴素的话说,Agent 并不是什么魔法——它就是一个 for step in range(max_steps) 循环,每一轮做三件事:

  • Think:把当前对话历史(含工具定义)发给 LLM,让模型决定“下一步说什么或调什么工具”;
  • Act:如果模型返回了 tool_calls,就按名字找到本地函数并执行;
  • Observe:把执行结果以 role: "tool" 的身份追加回 messages,下一轮 LLM 就能看到。

终止条件只有两个:LLM 不再请求工具(返回纯文本),或者触发了兜底(超时/最大步数)。理解这一点,Agent 的神秘感就消失了大半。

2. 工具定义:Schema 是给 LLM 看的“接口文档”

每个工具需要两样东西:一个 Python 函数(真正干活)和一个 JSON Schema(告诉 LLM 这个工具叫什么、干什么、参数是什么)。文章特别强调:description 写得好不好,直接决定 LLM 会不会调错工具,“像给初级开发者写 docstring 一样认真”。

在代码中,TOOL_FUNCTIONS 字典是“执行侧”的映射,TOOLS 列表是“描述侧”的载荷,两者必须对齐——名字、参数名、必填项都要一致,否则会出现“LLM 按 Schema 传了参数,但 Python 函数不认”的错位。

3. Function Calling 的“配对键”:tool_call_id

这是文章点出的一个关键细节:role: "tool" 的消息必须带 tool_call_id,且该 ID 必须与上一轮 assistant 消息中对应 tool_call 的 id 完全一致。OpenAI API 靠这个 ID 把“工具请求”和“工具结果”配对起来。漏掉它,下一轮调用会直接报错。

另一个配套细节是:msg 是 SDK 返回的 ChatCompletionMessage 对象,必须调用 msg.model_dump()转成 dict 再 append 进 messages,否则后续序列化可能出问题。

4. 两层超时:总超时不够,工具超时必须有

文章指出一个容易被忽略的陷阱:循环开头的 time.time() - start > timeout 检查只在每轮 LLM 调用前生效。如果某个工具执行本身卡死(读超大文件、网络请求挂起),总超时检查根本不会被触发,整个 Agent 就永久卡住了。

因此需要两层超时:

  • 总超时:整个循环的墙上时间上限;
  • 工具超时:单次工具执行的墙上时间上限。

示例代码用 signal.alarm 实现了工具级超时。注意它仅限 Unix 主线程;跨平台场景可以改用 concurrent.futures.ThreadPoolExecutor 配合 future.result(timeout=...)。

5. 安全细节:计算器不用 eval,文件路径防遍历

文章用两个具体例子展示了“工具不是随便写的”:

计算器:即使你用 ast.parse 拦截了 ast.Call,eval 仍然不安全——__import__('os').system('rm -rf /') 并不走 ast.Call 节点。正确做法是白名单运算符递归求值:只注册 Add/Sub/Mult/Div/Pow 等允许的 AST 节点类型,遇到白名单外的节点直接抛异常。示例代码中的 _SAFE_OPS 和 _eval_node 就是这一思路的实现。

read_file:必须检查 os.path.abspath(file_path) 是否以允许的根目录开头,否则 ../../etc/passwd这样的路径可以轻松跳出限制。示例代码在 read_file 开头做了这个检查,返回错误而不是抛出异常,让 LLM 能看到并自行调整。

6. 容错哲学:把错误变成 Observation,让 LLM 自己兜底

文章反复强调一个 Agent 设计原则:工具执行失败不要崩,把错误信息喂回去。具体来说:

  • 工具内部抛出的异常 → 包成 {"success": false, "error": "..."} 作为 observation;
  • LLM 幻觉出不存在的工具名 → 返回 {"error": "未知工具:xxx"},LLM 下一轮通常会换正确的工具;
  • 参数 JSON 解析失败 → 同样喂回错误信息。

这背后的逻辑是:LLM 本身就是最好的“错误恢复器”。你不需要在代码里预判所有失败模式,只需要保证任何失败都不会让循环崩溃,并把失败原因如实告诉模型。

7. 完整流程一句话总结

用户输入 → 进入循环 → LLM 看到历史 + 工具描述 → 要么直接回答(结束),要么请求工具 → 代码执行工具(带超时、带沙箱、带异常兜底)→ 结果以 tool_call_id 配对的方式喂回 → 重复,直到 LLM 给出最终回答或触发兜底。整个过程的核心代码不到 50 行,剩下的都是工具本身和安全/超时的工程细节。

评论

还没有评论,来说两句?

评论已关闭