理解 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 怎么思考,接下来让它知道更多。

相关推荐
Hyyy6 小时前
如何设计Agent的Harness
llm·agent·ai编程
nbtang20268 小时前
AI Agent 入门(三):Tool Use 入门 —— Function Calling 原理与实战
人工智能·ai·agent
新知图书8 小时前
RAG之生成技术
人工智能·agent·ai agent·智能体·langgraph
武子康9 小时前
调查研究-211 AgentBound 深度解析:AI Agent 不只要“有权限”,还要有可验证的行为治理
人工智能·llm·agent
aovenus9 小时前
Skill / Agent / Workflow 使用场景指南及对比
agent·workflow·skill
JouYY11 小时前
聊一下知识答疑Agent的“层次聚类”流程
架构·llm·agent
L3S11 小时前
Agent为什么会死循环?
人工智能·agent
云烟成雨TD11 小时前
LangFlow 1.x 系列【3】入门案例
人工智能·python·agent
墨流藏于库12 小时前
Electron 应用 macOS 自动更新的正确姿势 —— 没有 Apple Developer Program 也能用
agent
新知图书12 小时前
智能体基础架构
人工智能·agent·ai agent·智能体·langgraph