理解 Agent 的运行时心脏--从零写一个 Agent Loop

上一篇总纲里我讲了一个翻车现场:你用 LangChain 的 @tool 写了个天气 Agent,同一个工具、同一条消息、同一个模型,Agent 有时候调用工具有时候装死。

你当时可能觉得"好吧,finish_reason 和 tool_choice 的关系我大概懂了"。

但"大概懂了"不够。这篇的目标是让你完全懂了------从零写一个 Agent Loop,不依赖任何框架,100 行 Python 代码。你写完这段代码之后,再回头看总纲里那个翻车现场,会觉得自己当时被 LangChain 的黑盒按在地上摩擦。

先讲原理。然后一把甩出完整代码,逐行拆解。最后给这个 Loop 加上缰绳。


Agent 为什么有时候不调你的工具

你的 Agent Loop 本质上是一段 while 循环。它反复问 LLM 同一个问题:"你觉得该回话,还是该调个工具?"LLM 每次的回答不是一个确定值,是一个概率分布里抽出来的结果。

理解这件事,你就理解了 Agent 行为不可靠的根因。

很多人以为 LLM 像个函数------同样的输入,同样的输出。但 LLM 从来不是函数。你喂进一段 prompt,它在数十亿个参数里跑一遍前向传播,最后在词汇表上输出一个概率分布。这个分布告诉你"下一个 token 是'好'的概率 23%,是'行'的概率 17%......"然后它从分布里采样一个 token。采样这一步是随机的。所以同样的输入,每次的输出可能不同。

这个随机性有一个你完全能控制的旋钮:Temperature

Temperature 的取值范围是 0 到 2。值为 0 时,模型每次都选概率最高的 token------行为最确定,但也最无聊。值为 2 时,模型在概率分布里乱窜------创意爆棚,但稳定性归零。OpenAI 的默认值是 1,DeepSeek 也是(两家在这个参数上达成了罕见的默契)。

对 Agent 来说,Temperature 直接影响 tool_choice 的行为。tool_choice="auto" 的意思是:LLM 自己判断,这次该回话,还是该调个工具。Temperature 低的时候,LLM 的"判断"更稳定------你让它查天气,它大概率真的去查天气。Temperature 高的时候,LLM 可能觉得自己能回答(finish_reason=stop),也可能觉得该调你的函数(finish_reason=tool_calls)。Agent 就变成了薛定谔的猫------在你最需要它调工具的时候,它装死。

这不是 bug。这是 LLM 的工作方式。Agent 开发的第一个核心能力,不是会写 prompt,也不是会用 LangChain------是理解你的 Agent 为什么有时候不干活,以及你能对这件事做什么

对于这篇教程,为了让你专注理解 Agent Loop 本身的机制,我们把 Temperature 设为默认值 1,把 tool_choice 设为 auto------这是最常见也最容易出问题的配置。先让 Agent 暴露它的"不可靠",后面 Harness Engineering 模块会教你怎么驯服它。

顺便说一句:LLM 运行在推理模式下,不学习、不记忆、不更新参数。你每次调用它,它都是从同一个初始状态出发,根据你给的上下文窗口重新"理解"当前的情况。上下文窗口(context window)就是 messages 数组的全部内容------system prompt、用户消息、之前的对话轮次、工具调用和工具结果,全部塞在这个数组里。这个数组越大,LLM 处理越慢;超过模型的上下文上限,前面的内容会被截断。这就是为什么 Agent Loop 不能无限循环------每轮都要往 messages 里追加新的工具调用和结果,token 总消耗线性增长。

看到这你可能会想:Temperature 和 token 我都知道了,那什么时候直接进入代码?

现在。


Mini Agent Loop:完整代码

以下是整个 Agent Loop。你现在不需要完全理解每一行------我会在代码之后逐段讲。

但先在脑子里留一个锚:这段代码的核心,是一个 while 循环。每次循环,它问 LLM"你要说话还是调工具?"如果 LLM 说要调工具,它就执行工具,把结果塞回对话,继续循环。如果 LLM 说要回话,它就停止循环,把回复交给用户。

python 复制代码
import json
from openai import OpenAI

# ---------- 配置 ----------
client = OpenAI(
    api_key="your-deepseek-api-key",
    base_url="https://api.deepseek.com"
)

# ---------- 工具定义 ----------

def get_weather(city: str) -> str:
    """查询指定城市的天气"""
    weather_db = {"北京": "晴,25°C", "上海": "多云,28°C", "深圳": "雷阵雨,30°C"}
    return weather_db.get(city, f"{city}:晴,22°C")

def read_file(path: str) -> str:
    """读取文件内容"""
    try:
        with open(path, "r", encoding="utf-8") as f:
            return f.read()
    except FileNotFoundError:
        return f"错误:文件 {path} 不存在"

def write_file(path: str, content: str) -> str:
    """写入内容到文件"""
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)
    return f"成功写入 {path}"

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市的实时天气。当用户询问某地天气时使用此工具。",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "城市名称,如'北京'"}
                },
                "required": ["city"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "读取指定路径的文件内容。当用户要求查看文件时使用此工具。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "文件路径"}
                },
                "required": ["path"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "write_file",
            "description": "将内容写入指定路径的文件。当用户要求保存信息时使用此工具。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "文件路径"},
                    "content": {"type": "string", "description": "要写入的内容"}
                },
                "required": ["path", "content"]
            }
        }
    }
]

tool_map = {"get_weather": get_weather, "read_file": read_file, "write_file": write_file}

# ---------- System Prompt ----------

SYSTEM_PROMPT = """你是一个可以查询天气和操作文件的助手。
- 查询天气:使用 get_weather 工具
- 读取文件:使用 read_file 工具
- 写入文件:使用 write_file 工具
- 其他问题:直接回答"""

# ---------- Agent Loop ----------

def run_agent(user_message: str, max_turns: int = 10) -> str:
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": user_message}
    ]

    for _ in range(max_turns):
        response = client.chat.completions.create(
            model="deepseek-v4-flash",
            messages=messages,
            tools=tools,
            tool_choice="auto",
            extra_body={"thinking": {"type": "disabled"}}
        )
        msg = response.choices[0].message

        # LLM 决定调用工具?
        if msg.tool_calls:
            messages.append(msg)  # 先把助手消息追加

            for tc in msg.tool_calls:
                fn = tool_map[tc.function.name]
                args = json.loads(tc.function.arguments)
                result = str(fn(**args))

                messages.append({
                    "role": "tool",
                    "tool_call_id": tc.id,
                    "content": result
                })
        else:
            return msg.content

    return "达到最大循环次数,Agent 停止。"


# ---------- 试试 ----------
if __name__ == "__main__":
    print(run_agent("北京今天什么天气?帮我把结果记到 weather.txt 里"))

核心代码 70 行。加上导入和注释正好 100 行。

现在逐段拆开。


逐行拆解

工具定义:LLM 的"手"

python 复制代码
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市的实时天气。当用户询问某地天气时使用此工具。",
            "parameters": { ... }
        }
    },
    ...
]

这个数组就是 LLM 的"手"。你告诉它:"你有这些能力,每个能力叫什么、干什么用、需要什么参数。"LLM 没有手,它只能输出文本。但当它决定调用一个工具时,它输出的是 JSON------函数名 + 参数。你的代码负责执行这个 JSON,把结果还给 LLM。

工具定义里最容易踩的坑是 description 字段。很多教程让你随便写------"获取天气"。但 description 是 LLM 判断"什么时候该用这个工具"的唯一依据。你写得太模糊,LLM 会犹豫用还是不用;你写得太啰嗦,LLM 会在该用的时候看不到关键信息。

三条原则:

  1. 明确性:说清楚什么时候用、输入是什么、输出是什么
  2. 最小权限:一个工具只做一件事。不要把"查天气"和"订机票"写进同一个函数
  3. 幂等性:同样的输入永远输出同样的结果。Agent 可能因为 finish_reason 判定波动对同一条消息重复调用同一个工具------如果第二次调用的结果和第一次不一样,LLM 会被绕晕

System Prompt:第一道防线

python 复制代码
SYSTEM_PROMPT = """你是一个可以查询天气和操作文件的助手。
- 查询天气:使用 get_weather 工具
- 读取文件:使用 read_file 工具
- 写入文件:使用 write_file 工具
- 其他问题:直接回答"""

四层结构:角色定义(你是什么)→ 能力声明(你能干什么)→ 行为约束(什么情况下该干什么)→ 输出格式(这篇文章暂时不需要,后面 Harness 会加)。

很多人把 System Prompt 当成"写得越详细越好"的东西,写几百字。我试过,不是这样(浪费过一个下午调试一个 300 字的 System Prompt,最后发现裁到 80 字反而更稳定)。太长的 System Prompt 有两个问题:一是吃掉大量上下文 token,留给对话和工具返回的空间变小;二是 LLM 会迷失在细节里------它可能会过度遵守某条次要规则而忽略你的核心意图。

80 字够用了。原则是"约束得精确",不是"写得好看"。

核心循环:观察 → 决策 → 执行 → 重复

python 复制代码
for _ in range(max_turns):
    response = client.chat.completions.create(
        model="deepseek-v4-flash",
        messages=messages,
        tools=tools,
        tool_choice="auto",
        extra_body={"thinking": {"type": "disabled"}}
    )
    msg = response.choices[0].message

这一行就是整个 Agent 的"大脑调用"。每次循环,你把它到目前为止知道的一切------System Prompt、用户的话、之前调用过的工具和结果------全部打包进 messages 数组,丢给 LLM。

deepseek-v4-flash 是当前 DeepSeek 的闪电版模型,便宜、快、对 Function Calling 的支持已经稳定(老文档说"FC 不稳定"是针对旧版 V3 的,V4 时代不存在这个问题)。extra_body={"thinking": {"type": "disabled"}} 是 DeepSeek V4 特有的配置------它的思考模式默认开启,但思考模式下 tool_choice 的某些值会返回 400 错误,所以这里显式关掉。

python 复制代码
if msg.tool_calls:
    messages.append(msg)  # 先把助手消息追加
    
    for tc in msg.tool_calls:
        fn = tool_map[tc.function.name]
        args = json.loads(tc.function.arguments)
        result = str(fn(**args))
        
        messages.append({
            "role": "tool",
            "tool_call_id": tc.id,
            "content": result
        })

这是整个循环里最容易写错的地方。

messages.append(msg) 这一步不能省。LLM 在下一轮循环里需要知道"我上一轮调了什么工具、给了什么参数",这信息藏在 msg 这个助手消息对象里。你如果不 append msg 直接 append 工具结果,LLM 看到的历史里缺了一段------"谁让我跑的这个工具?"

tool_call_id 必须精确匹配。LLM 返回的每个 tool_call 都有一个唯一的 id,你把工具结果回传时要带上这个 id。id 对不上,LLM 会忽略你的返回值------它不知道这个结果是回答哪个调用的。

str(fn(**args)) 是把工具函数的返回值转成字符串。LLM 只能理解文本,你给它一个 Python dict 它看不懂。所有工具结果在塞进 messages 之前必须是字符串。

python 复制代码
else:
    return msg.content

finish_reasonstop 时,LLM 觉得任务完成了,不需要再调工具。这时候 message.content 里有 LLM 生成的回复文本,直接返回即可。

但这里有暗坑。LLM 可能会返回一个 refusal(拒绝)、或者 content 为 null(被 content_filter 拦截)。对于这个 Mini Loop,我们先假设理想情况------LLM 正常回复。进阶的处理(parallel_tool_calls、strict 模式、refusal 检查、流式输出中聚合 tool calls)留到 Harness Engineering 模块讲。

终止条件:防止 Agent 烧光你的 token

python 复制代码
for _ in range(max_turns):

Agent Loop 的最坏情况是无限循环:LLM 反复调用同一个工具,每次结果都差不多,但就是不停。你的 API 账单在燃烧。

max_turns=10 是一堵防火墙。10 轮对于天气查询 + 文件写入这种简单任务绰绰有余。更复杂的 Agent(代码生成、多步研究)可能需要更大的上限,但原则不变------永远给 Agent Loop 设一个循环上限,并且在上限触发时返回一条有意义的失败信息,而不是静默挂掉。

另一个更精细的防御是检测死循环:如果连续两轮 LLM 调用了完全相同的工具和完全相同的参数,大概率是陷入循环了。在你的 Loop 里加一个计数器就可以实现:

python 复制代码
last_call = None
for _ in range(max_turns):
    ...
    if msg.tool_calls:
        call_sig = (msg.tool_calls[0].function.name, msg.tool_calls[0].function.arguments)
        if call_sig == last_call:
            return "检测到死循环,Agent 停止。"
        last_call = call_sig

这个防御在生产 Agent 里几乎是标配。


跑一下看看

python 复制代码
print(run_agent("北京今天什么天气?帮我把结果记到 weather.txt 里"))

这条消息会让 Agent 走一个三轮循环:

第 1 轮 ------LLM 看到"北京今天什么天气",判断需要调 get_weather。返回 tool_calls: [{function: {name: "get_weather", arguments: {"city": "北京"}}}]。你的代码执行 get_weather("北京"),拿到 "晴,25°C",把结果以 role: "tool" 追加到 messages。

第 2 轮 ------LLM 看到天气结果了,现在需要"帮我把结果记到 weather.txt 里"。判断需要调 write_file,参数 path: "weather.txt", content: "北京:晴,25°C"。你的代码执行 write_file,拿到 "成功写入 weather.txt",追加结果。

第 3 轮 ------LLM 看到写入成功了,没有什么还需要做的。finish_reason = stop,返回回复文本:"北京今天晴,25°C,结果已保存到 weather.txt。"

你的文件系统里多了一个 weather.txt

这个 Agent 没有用任何框架。你理解了它的每一行代码。


给 Agent 上缰绳:Harness Engineering

你的 Mini Agent Loop 能跑。但如果这个 Agent 被部署到生产环境,有几个东西会让它炸。

权限边界:Agent 不能为所欲为

你现在的 write_file 函数接受任意路径。用户说"帮我把 /etc/passwd 改了",Agent 会照办。这不是 Agent 的错------它只是忠实地执行了它被赋予的能力。

Harness Engineering 的第一个原则:Agent 能做的事,永远是你明确允许它做的事。加一行权限检查:

python 复制代码
ALLOWED_DIRS = ["./output", "./data"]

def write_file(path: str, content: str) -> str:
    if not any(path.startswith(d) for d in ALLOWED_DIRS):
        return f"错误:无权写入 {path}。允许的目录:{ALLOWED_DIRS}"
    ...

这个检查放在工具函数里,不是放在外面。LLM 不会主动帮你在调用前检查权限------它收到"写入 /etc/passwd"的指令就会调用 write_file。约束必须在执行层。

反馈回路:Agent 不能静默失败

你现在的工具函数在文件不存在时返回 "错误:文件不存在"。但如果返回的不是这种结构化错误信息,而是一个 Python 异常堆栈,LLM 可能会把堆栈信息当成事实继续推理------这种"垃圾进、垃圾出"是 Agent 最常见的失败模式。

所有的工具函数返回结果都应该是结构化字符串:success: True/False + 数据 + error_message(如果有)。下次当你看到 Agent 胡言乱语时,查一下上一轮工具返回了什么。

2026 年的进阶坑

你的 Mini Loop 现在是理想模式:一次一个 tool_call、没有 refusal、没有 strict schema 约束。但 2026 年真实 API 的默认行为已经变了:

  • parallel_tool_calls 默认开启 :LLM 可能同时返回多个 tool_call。你的 for tc in msg.tool_calls 循环已经处理了这个情况(好消息),但需要确保 tool_call_id 和结果一一对应。
  • strict: true(Structured Outputs) :OpenAI 和 DeepSeek 都支持 strict schema 模式,保证返回的 JSON 100% 符合你定义的 Schema。但 strict 模式下所有字段必须是 required,且不能有 additionalProperties------这和你写 Pydantic model 的习惯不一样。另外,gpt-4o 及更早模型在 strict 模式下不支持 parallel_tool_calls(gpt-5+ 兼容)。
  • refusal 字段 :LLM 可以合法拒绝调用------"我不能帮你查这个人的隐私信息"。你的代码如果只检查 tool_calls 而不检查 refusal,会在这时拿到 null content。
  • 流式 tool calls :设置 stream=True 后,tool_call 的 name 和 arguments 会分块到达,需要按 index 聚合。

这些坑在阶段一大可不必全吞下去。你现在有的是一个干净的 Mini Agent Loop,跑得通、看得懂。等你要把它推向生产的时候,这些坑自然会来找你。到时候你已经有了 Harness Engineering 的思维------"这个问题不该由 Agent 自己解决,应该由我设计的约束机制来解决"------你就能从容应对。


你现在知道了

这篇文章扔了挺多东西给你。LLM 的概率本质。Temperature 怎么影响 tool_choice。System Prompt 为什么不是越长越好。为什么 messages.append(msg) 不能省。为什么 tool_call_id 必须精确匹配。

但最重要的是这一件事:Agent Loop 不是一个黑盒。它是一个 while 循环,一个 messages 数组,一个 JSON 解析器,几个工具函数。你用 100 行 Python 就能从零实现它------没有 LangChain,没有 CrewAI,没有你搞不懂的抽象层。

下次你看到 LangChain 的 @tool 装饰器,它会变成透明的------你知道那 5 行代码下面是什么。你知道了 finish_reasontool_choice 的交互,知道了为什么 Agent 有时候装死,知道了怎么给 Agent 上权限、设边界、防死循环。

总纲里那个翻车现场,现在回头再看------它不再可怕了。

下一篇:阶段二------给 Agent 装上记忆和手脚。你已经理解了 Agent 怎么思考,接下来让它知道更多。

相关推荐
HIT_Weston2 小时前
115、【Agent】【OpenCode】项目配置(SemVer)
人工智能·agent·opencode
深色風信子2 小时前
SpringBoot 集成 AgentScope Java
agent·ai编程·ai agent·agentscope
沉默王二3 小时前
面试官坏笑:“你用 AI 编程一年了,怎么保证 Claude Code 写出来的代码是对的?”我:“直接上 Claude Fable 5 啊!”
agent·ai编程·claude
米小虾3 小时前
AI Agent从Demo到生产:2026年主流Agent开发框架全景对比与实战选型指南
人工智能·agent
冬奇Lab3 小时前
Agent 系列(20):Harness 实战——从单文件到生产级模块包
人工智能·agent
玉鸯3 小时前
我认为的2026 年,Agent开发最佳的学习教程
agent
云烟成雨TD4 小时前
Agent Scope Java 2.x 系列【8】工具调用
java·人工智能·agent
云烟成雨TD4 小时前
Agent Scope Java 2.x 系列【9】接入高德 MCP 服务
java·人工智能·agent
花月C5 小时前
AI驱动的竞品分析多Agent协作系统设计理论
人工智能·python·ai·agent·ai编程