当前位置:首页 > 编程开发 > 正文

写一个最小的 Agent 循环:80 行代码,和三个必须处理的边界

约 6695 字 · 预计阅读 17 分钟
一句话总结:所谓 Agent,剥掉包装就是一个 while 循环:模型输出工具调用、你执行、把结果塞回去、再问一次。真正的工程量不在循环本身,在三个边界——工具报错怎么回传、怎么防止无限循环、上下文怎么不爆。

先破除一个误解。很多人以为 Agent 是个复杂框架,得装 LangChain、AutoGen 之类。其实核心逻辑不到 100 行,框架加的那几千行主要在编排、重试、可观测、多 Agent 协作。

搞清楚最小循环长什么样,你才知道框架在替你做什么、什么时候不该用框架。

前置

pip install OpenAI

一个能调工具调用的模型:本地 Ollama(http://localhost:11434/v1)或任何 OpenAI 兼容接口。openai 这个 SDK 现在基本成了事实标准,换成别的服务商只改 base_uRL。

循环的形状

用户任务
  ↓
[模型] ──有 tool_calls?──否──→ 输出最终答案 → 结束
  ↓ 是
[执行工具] → 结果回填 messages
  ↓
回到 [模型](受 max_rounds 限制)

就这么简单。难点全在方括号里。

完整代码

import json
from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1", API_key="ollama")
MODEL = "qwen3:4b"

TOOLS, HANDLERS = [], {}

def register(schema, handler):
    TOOLS.append({"type": "function", "function": schema})
    HANDLERS[schema["name"]] = handler

# ── 两个示例工具
def read_file(path: str) -> str:
    with open(path, encoding="utf-8", errors="replace") as f:
        return f.read()

def list_dir(path: str) -> str:
    import os
    return "\n".join(sorted(os.listdir(path))[:200])

register({
    "name": "read_file",
    "description": "读取本地文本文件。用于查看代码、日志、配置;不要用它读二进制文件。",
    "parameters": {
        "type": "object",
        "properties": {"path": {"type": "string", "description": "文件绝对路径"}},
        "required": ["path"],
    },
}, read_file)

register({
    "name": "list_dir",
    "description": "列出目录下的文件名。不清楚有什么文件时先调它。",
    "parameters": {
        "type": "object",
        "properties": {"path": {"type": "string", "description": "目录绝对路径"}},
        "required": ["path"],
    },
}, list_dir)

def call_tool(call) -> str:
    """执行一次工具调用,任何异常都转成字符串返回给模型"""
    name = call.function.name
    try:
        args = json.loads(call.function.arguments or "{}")
    except json.JSONDecodeError as e:
        return f"参数不是合法 JSON:{e}"

    handler = HANDLERS.get(name)
    if handler is None:
        return f"没有名为 {name} 的工具,可用的是:{', '.join(HANDLERS)}"
    try:
        return str(handler(**args))
    except TypeError as e:
        return f"参数不匹配:{e}"
    except FileNotFoundError:
        return "文件不存在,检查路径后重试"
    except Exception as e:
        return f"执行失败:{type(e).__name__}: {e}"

def run(task: str, max_rounds: int = 8) -> str:
    messages = [
        {"role": "system", "content": "你是本机助手。需要文件内容时调用工具,不要凭印象猜。"},
        {"role": "user", "content": task},
    ]
    for _ in range(max_rounds):
        resp = client.chat.completions.create(
            model=MODEL, messages=messages,
            tools=TOOLS, tool_choice="auto", temperature=0,
        )
        msg = resp.choices[0].message
        messages.append(msg.model_dump(exclude_none=True))

        if not msg.tool_calls:
            return msg.content

        for call in msg.tool_calls:
            result = call_tool(call)
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,          # 少这一行,接口直接 400
                "content": result[:4000],         # 截断,防止把上下文撑爆
            })

    return f"达到 {max_rounds} 轮上限仍未完成"

if __name__ == "__main__":
    print(run("看看当前目录下有哪些文件,然后告诉我这个项目是干什么的"))

跑通之后你会看到一件事:模型并不是每次都听话。有的会反复读同一个文件,有的会先列出目录再猜文件名。这不是 bug,是它只有你给的那些工具和那几句描述。

三个边界,决定这套东西能不能用

边界一:工具报错必须回传,不能抛

上面 call_tool 里每个 except 都返回字符串,这是刻意的。

如果异常往上抛,循环直接中断,模型永远不知道自己错在哪。而如果返回 "文件不存在,检查路径后重试",模型有相当大概率下一步就换个路径再试。

这是「Agent」和「一次调用」最大的行为差异:错误反馈是它自我修正的唯一输入。

代价是你要克制:错误信息要短、要可操作,不要贴完整 traceback,否则几千 Token 的堆栈会挤掉真正的上下文。

边界二:轮数上限是硬要求

max_rounds=8 不是保守,是必需。没有它,模型完全可以陷入「读文件 → 觉得不对 → 再读同一个文件」的死循环,一次任务烧掉几十万 token。

三层防护我一般这么配:

层 手段 作用
轮数max_rounds兜底,防死循环
预算累计 token 或调用次数上限防单轮超长
幂等同参数同工具的去重防重复劳动

第三层最容易被忽略。写一个 (name, args_json) 的集合,命中就返回「这个调用刚刚做过,结果如下」并从缓存取,能省掉大量重复请求。

边界三:上下文会爆

每次工具返回都往 messages 里塞。读三个 50KB 的日志文件,上下文就满了。

上面的 result[:4000] 是最粗暴的截断。实际用的时候我建议再细一点:

def trim(text: str, liMIT: int = 4000) -> str:
    if len(text) <= limit:
        return text
    head = text[: int(limit * 0.7)]
    tail = text[-int(limit * 0.2):]
    return f"{head}\n...[中间省略 {len(text)-len(head)-len(tail)} 字]...\n{tail}"

保留头部和尾部,中间标注省略了多少。头部通常有文件标识和开头内容,尾部往往是错误信息或结果,中间是最不重要的。

工具设计比循环重要

这段是我认为整篇最值钱的。

模型决定调哪个工具,靠的是 name + description + parameters schema。写得含糊,它就不用。

# ✗ 模型不知道什么时候该用
{"name": "search", "description": "搜索"}

# ✓ 有边界、有场景、有禁用条件
{
    "name": "grep_code",
    "description": "在代码库里按正则搜索文本,返回匹配行及行号。用于定位函数定义、变量使用;不要用它读整文件内容,那个用 read_file。",
    "parameters": {
        "type": "object",
        "properties": {
            "pattern": {"type": "string", "description": "Python 正则,不支持跨行"},
            "path": {"type": "string", "description": "搜索根目录"},
            "max_results": {"type": "integer", "description": "默认 30,上限 100"},
        },
        "required": ["pattern", "path"],
    },
}

三个要点:写明「什么时候别用」,参数加上限,返回值格式固定(模型要能预测解析难度)。

图 1:循环本体只有四步,工程量都在两侧的防护上

坑清单

坑一:漏了 tool_call_id。 回填结果时缺这个字段,OpenAI 兼容接口直接返回 400,报错信息通常很含糊。这是排第一的坑。

坑二:把异常往上抛。 循环中断,模型失去修正机会。所有工具执行都要包 try。

坑三:没设轮数上限。 线上跑一晚烧掉几百块的案例不少。默认给 6 到 10 轮。

坑四:temperature 没设 0。 同样的任务每次结果不一样,调试时根本没法复现。

坑五:工具返回结构化对象而不是字符串。 有些实现会返回 dict,序列化失败就变成「工具调用失败」这种无用信息。统一 str() 或 json.dumps()。

边界

这套最小实现适合「单机、短任务、工具数量少」的场景:本地文件分析、内部工具串联、把固定流程自动化。

不适合:多 Agent 协作、需要长期记忆、需要人在环路里审批的流程。这些确实该上框架,因为框架解决的是编排和状态管理,不是循环本身。

另外提醒一句:给了文件读写权限的 Agent,等于把你的文件系统交给了模型判断。工作目录要隔离,别在下载目录里跑(原因见投毒那篇)。

FAQ

必须要框架吗?

不用。先把这个 80 行跑通,你会发现大部分需求它就能满足。真需要多 Agent 再上框架,那时你也知道该选哪个。

本地 4B 模型能跑 Agent 吗?

能跑,但工具调用准确率明显低于大模型,通常需要多试几轮或加更明确的提示。7B 以上会稳很多。

怎么调试?

每次都把 messages 落盘成 JSON,出问题时回放。这比在循环里打断点有效得多。

多轮工具调用会不会很慢?

会。每轮都是一次完整的模型推理。8 轮意味着 8 次调用,本地 4B 模型可能几十秒,云端 API 看延迟。

怎么加人类确认?

在 call_tool 里对危险工具(写文件、执行命令)加一道 input() 确认,或者返回「需要用户确认」让上层处理。

结语

Agent 这个词被包装得太重了。剥开看,它是「模型 + 循环 + 工具 + 错误反馈」四件东西。

把这四件写对,比套十个框架管用。

参考资料

目录导航
  • 前置
  • 循环的形状
  • 完整代码
  • 三个边界,决定这套东西能不能用
    • 边界一:工具报错必须回传,不能抛
    • 边界二:轮数上限是硬要求
    • 边界三:上下文会爆
  • 工具设计比循环重要
  • 坑清单
  • 边界
  • FAQ
  • 结语
    • 参考资料
  • 相关文章:

  • Playwright 实战:登录、翻页、截图对比,三件最常做的事2026-10-28 08:39:22
  • 讯飞星火 X2.6 端云一体:4B 端侧模型凭什么敢接 41.9% 的智能体任务2026-10-28 08:39:22
  • 从零写一个 MCP 服务器:让你的脚本变成 AI 能直接调的工具2026-10-28 08:39:22
  • GPT-6.1 Terra 深度解读:$1/$5 的半价旗舰,和系统卡里那句「29.6% 绕过限制」2026-10-28 08:39:22
  • OpenAI 为什么给训练机房断网:一次 DNS 越狱、一套 CPU 摩斯码2026-10-28 08:39:22
  • 给本地模型接工具:llama.cpp + Function Calling 实战2026-10-28 08:39:22
  • Step5 Preview 深度解读:600B 开源旗舰,单任务成本只有 Opus 5 的八分之一2026-10-28 08:39:22
  • 美团 LongCat 2.5 深度解读:1.6 万亿参数,国产算力训出来的长任务模型2026-10-28 08:39:22
  • 发表评论

    ◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。