01. Agent 大脑:主循环是怎么转起来的

01. Agent 大脑:主循环是怎么转起来的

从零到一实现一个 AI Agent 框架 · 第一篇


1. 为什么需要 Agent Loop?

先看一个直接的对比。

普通 LLM 调用:

复制代码
用户:帮我查一下 AAPL 的股价,然后写个分析报告
      ↓
LLM:抱歉,我无法实时查股价,但我可以告诉你...

卡住了。模型知道数据截止日期,拒绝回答实时问题。

加了 Agent Loop 之后:

复制代码
用户:帮我查一下 AAPL 的股价,然后写个分析报告
      ↓
LLM:[思考] 我需要查股价 → 调用 get_stock_price("AAPL")
      ↓
      获取到结果:$178.23
      ↓
LLM:[思考] 数据拿到了,写分析报告...
      ↓
      回复用户完整的分析

区别在哪?后者会自主决定"我还需要做什么",然后去做,再把结果纳入思考。

这个"思考→行动→观察→继续思考"的循环,就是 Agent Loop(智能体主循环)

没有循环的 LLM 调用 = 高级计算器。有循环,才叫 Agent。


2. 从零开始:最小 Agent Loop

先别看 axon 的代码,我们自己推一遍------一个 Agent Loop 最少需要什么?
#mermaid-svg-0lTAUheOrGMLSD5J{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0lTAUheOrGMLSD5J .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0lTAUheOrGMLSD5J .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0lTAUheOrGMLSD5J .error-icon{fill:#552222;}#mermaid-svg-0lTAUheOrGMLSD5J .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0lTAUheOrGMLSD5J .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0lTAUheOrGMLSD5J .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0lTAUheOrGMLSD5J .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0lTAUheOrGMLSD5J .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0lTAUheOrGMLSD5J .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0lTAUheOrGMLSD5J .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0lTAUheOrGMLSD5J .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0lTAUheOrGMLSD5J .marker.cross{stroke:#333333;}#mermaid-svg-0lTAUheOrGMLSD5J svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0lTAUheOrGMLSD5J p{margin:0;}#mermaid-svg-0lTAUheOrGMLSD5J .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-0lTAUheOrGMLSD5J .cluster-label text{fill:#333;}#mermaid-svg-0lTAUheOrGMLSD5J .cluster-label span{color:#333;}#mermaid-svg-0lTAUheOrGMLSD5J .cluster-label span p{background-color:transparent;}#mermaid-svg-0lTAUheOrGMLSD5J .label text,#mermaid-svg-0lTAUheOrGMLSD5J span{fill:#333;color:#333;}#mermaid-svg-0lTAUheOrGMLSD5J .node rect,#mermaid-svg-0lTAUheOrGMLSD5J .node circle,#mermaid-svg-0lTAUheOrGMLSD5J .node ellipse,#mermaid-svg-0lTAUheOrGMLSD5J .node polygon,#mermaid-svg-0lTAUheOrGMLSD5J .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-0lTAUheOrGMLSD5J .rough-node .label text,#mermaid-svg-0lTAUheOrGMLSD5J .node .label text,#mermaid-svg-0lTAUheOrGMLSD5J .image-shape .label,#mermaid-svg-0lTAUheOrGMLSD5J .icon-shape .label{text-anchor:middle;}#mermaid-svg-0lTAUheOrGMLSD5J .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-0lTAUheOrGMLSD5J .rough-node .label,#mermaid-svg-0lTAUheOrGMLSD5J .node .label,#mermaid-svg-0lTAUheOrGMLSD5J .image-shape .label,#mermaid-svg-0lTAUheOrGMLSD5J .icon-shape .label{text-align:center;}#mermaid-svg-0lTAUheOrGMLSD5J .node.clickable{cursor:pointer;}#mermaid-svg-0lTAUheOrGMLSD5J .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-0lTAUheOrGMLSD5J .arrowheadPath{fill:#333333;}#mermaid-svg-0lTAUheOrGMLSD5J .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-0lTAUheOrGMLSD5J .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-0lTAUheOrGMLSD5J .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0lTAUheOrGMLSD5J .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-0lTAUheOrGMLSD5J .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0lTAUheOrGMLSD5J .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-0lTAUheOrGMLSD5J .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-0lTAUheOrGMLSD5J .cluster text{fill:#333;}#mermaid-svg-0lTAUheOrGMLSD5J .cluster span{color:#333;}#mermaid-svg-0lTAUheOrGMLSD5J div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-0lTAUheOrGMLSD5J .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-0lTAUheOrGMLSD5J rect.text{fill:none;stroke-width:0;}#mermaid-svg-0lTAUheOrGMLSD5J .icon-shape,#mermaid-svg-0lTAUheOrGMLSD5J .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0lTAUheOrGMLSD5J .icon-shape p,#mermaid-svg-0lTAUheOrGMLSD5J .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-0lTAUheOrGMLSD5J .icon-shape .label rect,#mermaid-svg-0lTAUheOrGMLSD5J .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0lTAUheOrGMLSD5J .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-0lTAUheOrGMLSD5J .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-0lTAUheOrGMLSD5J :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 调用工具
直接回复
用户输入
LLM 思考
解析意图
执行工具
反馈结果
返回结果

整个循环就四个节点:LLM 思考 → 解析意图 → 执行工具 → 反馈结果 → 继续思考。直到 LLM 觉得够了,直接回复。

python 复制代码
# 最小 Agent Loop(伪代码)
def agent_loop(prompt):
    messages = [{"role": "user", "content": prompt}]

    while True:
        # 1. 思考:让 LLM 决定下一步做什么
        response = llm.chat(messages)

        # 2. 解析:LLM 想调用工具,还是直接回复?
        action = parse_action(response)

        if action.type == "reply":
            # 直接回复用户,结束循环
            return action.content

        elif action.type == "call_tool":
            # 3. 执行工具调用
            result = execute_tool(action.tool_name, action.args)

            # 4. 把结果放回上下文,继续循环
            messages.append(response)
            messages.append({"role": "tool", "content": result})

核心就 4 步:

步骤 做什么 为什么需要
思考 LLM 基于当前上下文决定下一步 Agent 的"大脑"
解析 从 LLM 输出中提取意图 把自然语言转成可执行指令
执行 调用工具/函数 Agent 的"手脚"
反馈 把结果放回上下文 让 LLM "看到"它做了什么

这就构成了一个闭环。就这么简单。

当然,这个"最小实现"有很多问题。看看下面的场景:

复制代码
用户:请写一篇 10000 字的分析报告
LLM:好,我先查数据...
     → 调用 search_web("AAPL financials")
     → 调用 search_web("AAPL competition")
     → 调用 search_web("AAPL risks")
     → ...(无限调用下去)

坏了,它停不下来。这就是工程要解决的问题。


3. 工程演进:循环需要解决哪些问题?

从最小实现到可用的 Agent Loop,要处理 4 个核心问题。

3.1 停不下来怎么办?

LLM 没有内置的"差不多了"的判断力。你需要强制限制:

复制代码
max_turns = 20      # 最多 20 轮工具调用
max_tokens = 4096   # 最多消耗 4096 tokens
max_time = 120      # 最多运行 120 秒

任何一个超了,强制结束循环。

设计原则: Agent Loop 必须有硬边界。LLM 负责"做什么",

你负责"不能做什么"。

3.2 上下文越来越长怎么办?

每轮循环都把新结果塞进 messages,对话会无限膨胀。两个问题:

  1. 成本爆炸:LLM 按 token 计费,越长越贵
  2. 质量下降:中间细节淹没关键信息,模型"迷失在中间"

常见策略:

策略 做法 代价
滑动窗口 只保留最近 N 轮 丢失早期上下文
摘要压缩 把旧对话压缩成摘要 信息有损
关键信息提取 只保留工具调用结果 需要设计提取规则

Axon 的做法是分层压缩:先试无损(裁剪),不行就摘要,再不行就丢弃非关键内容。

3.3 工具调用怎么注入?

LLM 怎么知道它能调用哪些工具?靠 System Prompt。

复制代码
# System Prompt 里的一段
你是一个 AI 助手,可以使用以下工具:
- get_stock_price(symbol): 获取股票当前价格
- search_web(query): 搜索互联网
- send_email(to, subject, body): 发送邮件

当你想调用工具时,请严格按以下格式输出:
<function_call>
{"name": "get_stock_price", "arguments": {"symbol": "AAPL"}}
</function_call>

但更好的做法是让 LLM 原生支持工具调用(Function Calling)。OpenAI、Claude、Gemini 都支持结构化工具输出,不需要自己解析文本。

3.4 出错了怎么办?

工具调用可能失败:网络超时、参数错误、权限不足。

复制代码
LLM: → 调用 delete_database()
      系统:权限不足,操作被拒绝
      ↓
LLM: 哦,那我... → 调用 delete_database()  // 又试了一次?!

Agent 可能不断重试同一个失败操作。你需要:

  1. 错误信息清晰返回:让 LLM 理解为什么失败
  2. 重试限制:同一个工具连续失败 N 次后,跳过
  3. 用户确认:危险操作先问用户

4. 代码解剖:Axon 的 Agent 循环

现在来看 Axon 的实际代码。核心文件是 src/agent.ts

先看整体结构:
#mermaid-svg-ERNWrYIEsJCEDCC6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ERNWrYIEsJCEDCC6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ERNWrYIEsJCEDCC6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ERNWrYIEsJCEDCC6 .error-icon{fill:#552222;}#mermaid-svg-ERNWrYIEsJCEDCC6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ERNWrYIEsJCEDCC6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ERNWrYIEsJCEDCC6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ERNWrYIEsJCEDCC6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ERNWrYIEsJCEDCC6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ERNWrYIEsJCEDCC6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ERNWrYIEsJCEDCC6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ERNWrYIEsJCEDCC6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ERNWrYIEsJCEDCC6 .marker.cross{stroke:#333333;}#mermaid-svg-ERNWrYIEsJCEDCC6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ERNWrYIEsJCEDCC6 p{margin:0;}#mermaid-svg-ERNWrYIEsJCEDCC6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ERNWrYIEsJCEDCC6 .cluster-label text{fill:#333;}#mermaid-svg-ERNWrYIEsJCEDCC6 .cluster-label span{color:#333;}#mermaid-svg-ERNWrYIEsJCEDCC6 .cluster-label span p{background-color:transparent;}#mermaid-svg-ERNWrYIEsJCEDCC6 .label text,#mermaid-svg-ERNWrYIEsJCEDCC6 span{fill:#333;color:#333;}#mermaid-svg-ERNWrYIEsJCEDCC6 .node rect,#mermaid-svg-ERNWrYIEsJCEDCC6 .node circle,#mermaid-svg-ERNWrYIEsJCEDCC6 .node ellipse,#mermaid-svg-ERNWrYIEsJCEDCC6 .node polygon,#mermaid-svg-ERNWrYIEsJCEDCC6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ERNWrYIEsJCEDCC6 .rough-node .label text,#mermaid-svg-ERNWrYIEsJCEDCC6 .node .label text,#mermaid-svg-ERNWrYIEsJCEDCC6 .image-shape .label,#mermaid-svg-ERNWrYIEsJCEDCC6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-ERNWrYIEsJCEDCC6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ERNWrYIEsJCEDCC6 .rough-node .label,#mermaid-svg-ERNWrYIEsJCEDCC6 .node .label,#mermaid-svg-ERNWrYIEsJCEDCC6 .image-shape .label,#mermaid-svg-ERNWrYIEsJCEDCC6 .icon-shape .label{text-align:center;}#mermaid-svg-ERNWrYIEsJCEDCC6 .node.clickable{cursor:pointer;}#mermaid-svg-ERNWrYIEsJCEDCC6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ERNWrYIEsJCEDCC6 .arrowheadPath{fill:#333333;}#mermaid-svg-ERNWrYIEsJCEDCC6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ERNWrYIEsJCEDCC6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ERNWrYIEsJCEDCC6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ERNWrYIEsJCEDCC6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ERNWrYIEsJCEDCC6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ERNWrYIEsJCEDCC6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ERNWrYIEsJCEDCC6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ERNWrYIEsJCEDCC6 .cluster text{fill:#333;}#mermaid-svg-ERNWrYIEsJCEDCC6 .cluster span{color:#333;}#mermaid-svg-ERNWrYIEsJCEDCC6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ERNWrYIEsJCEDCC6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ERNWrYIEsJCEDCC6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-ERNWrYIEsJCEDCC6 .icon-shape,#mermaid-svg-ERNWrYIEsJCEDCC6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ERNWrYIEsJCEDCC6 .icon-shape p,#mermaid-svg-ERNWrYIEsJCEDCC6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ERNWrYIEsJCEDCC6 .icon-shape .label rect,#mermaid-svg-ERNWrYIEsJCEDCC6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ERNWrYIEsJCEDCC6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ERNWrYIEsJCEDCC6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ERNWrYIEsJCEDCC6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 未超限
超限




LLM 主动停止
未停止
用户输入

  1. 初始化上下文

加载系统提示词
2. 进入循环

检查 max_turns
2.1 调用 LLM
强制结束
2.2 解析响应
有工具调用?
执行工具
权限检查
结果放回上下文
有回复文本?
追加到消息列表
2.3 检查终止条件
返回最终回复
2.4 上下文压缩
完成

现在看关键代码。我简化并注释了核心逻辑:

typescript 复制代码
// src/agent.ts(核心循环部分,简化版)

async function processMessage(input: string): Promise<string> {
    // 1. 初始化上下文,包含系统提示词和历史消息
    const context = initializeContext(input);

    // 2. 循环计数
    let turn = 0;
    const MAX_TURNS = 25;

    while (turn < MAX_TURNS) {
        turn++;

        // 2.1 调用 LLM
        const response = await llm.complete(context.messages);

        // 2.2 检查 LLM 是否主动停止
        if (response.stopReason === 'end_turn') {
            break;
        }

        // 2.3 处理工具调用
        for (const toolCall of response.toolCalls) {
            // 权限检查(后续文章详讲)
            await checkPermission(toolCall);

            // 执行工具
            const result = await executeToolCall(toolCall);

            // 把结果放回上下文
            context.addToolResult(toolCall.id, result);
        }

        // 2.4 如果 LLM 有文本回复,追加到消息列表
        if (response.text) {
            context.addAssistantMessage(response.text);
        }

        // 2.5 上下文压缩(如果太长)
        if (context.tokens > THRESHOLD) {
            context.compress();
        }
    }

    // 3. 组装最终回复
    return context.getFinalResponse();
}

几个关键设计点值得注意:

① 循环边界是硬性的

MAX_TURNS = 25 是硬编码上限。不管 LLM 多想继续,25 轮必须停。这个数字来自实践:大多数任务 5-10 轮内完成,25 是安全余量。

② 工具调用是批量的

注意代码里是 for (const toolCall of response.toolCalls) ------ LLM 一次可以请求多个并行工具调用。Axon 的处理方式是逐个执行,因为有些工具有副作用(如"发送邮件"和"删除文件"不该并行)。

③ 上下文压缩是触发式的

不是每轮都压缩,只在 token 数超过阈值时才触发。这里用了惰性策略:不提前优化,等问题出现了再处理。


5. 动手实验:跑一个自己的 Agent Loop

理论讲完了,动手跑一个。

准备工作

你需要一个 LLM API Key(OpenAI / Anthropic / 任意兼容的 API)。

最小实现

python 复制代码
# 最小 Agent Loop(Python + OpenAI SDK)
import json
from openai import OpenAI

client = OpenAI(api_key="your-key")

# 定义工具
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "获取城市天气",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string"}
                },
                "required": ["city"]
            }
        }
    }
]

def execute_tool(name, args):
    """实际执行工具"""
    if name == "get_weather":
        # 模拟天气查询
        return f"{args['city']}的天气:晴,25°C"
    return f"未知工具:{name}"

def agent_loop(user_input):
    messages = [{"role": "user", "content": user_input}]
    max_turns = 10
    turn = 0

    while turn < max_turns:
        turn += 1
        print(f"\n--- 第 {turn} 轮 ---")

        response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=tools,
            tool_choice="auto"
        )

        msg = response.choices[0].message

        if not msg.tool_calls:
            # LLM 直接回复,结束
            return msg.content

        messages.append(msg)

        for tool_call in msg.tool_calls:
            name = tool_call.function.name
            args = json.loads(tool_call.function.arguments)
            print(f"→ 调用工具:{name}({args})")

            result = execute_tool(name, args)
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result
            })

    return "已达最大轮数,强制结束。"

# 跑起来
result = agent_loop("北京的天气怎么样?适合散步吗?")
print(f"\n最终回复:{result}")

把上面代码保存为 agent_loop.py,填入你的 API Key,然后运行:

bash 复制代码
python agent_loop.py

你会看到 LLM 先调工具拿数据,再基于数据生成回复------这就是一个完整的 Agent Loop。

实验一下

改几个参数看看行为变化:

  1. max_turns 改成 1 → LLM 没机会调工具就被强制结束
  2. 给一个复杂任务:"帮我规划一次旅行,查 3 个城市的天气,推荐最佳目的地"
  3. 让工具返回错误 :把 execute_tool 的返回值改成 "error: 服务不可用",看看 LLM 怎么应对

下一篇预告: 让 Agent 有手有脚 ------ 工具系统的设计与演化

当 LLM 想调工具时,工具从哪来?参数怎么校验?结果怎么返回?下一篇从零搭建一个完整的工具系统。