在这里插入图片描述

从“只会聊天”到“真能干活”:一文讲透 AI Agent 到底是什么

别人说“我写了个 Agent”,他到底写了什么?答案会让很多人失望——他写的那部分,基本没有 AI。


一、先搞清楚:大模型和 Agent,根本不是一回事

很多人把“大模型”和“Agent”混着用,这是第一个要纠正的误解。

大语言模型(LLM) 本质上就是一个吃文字、吐文字的函数。你给它一段话,它还你一段话。它不能读文件、不能上网、不能执行命令、也记不住你五分钟前说了什么。

那为什么你在 ChatGPT 里跟它聊十轮,它好像都记得?

真相是:你的聊天软件在第十轮时,把前面九轮的全部对话重新发了一遍。 模型那边根本不知道有“第十轮”这回事,它只是收到了一大段文字(恰好包含之前所有的聊天记录),然后接着写下去。

模型像一个每天早上彻底失忆的专家。你要他接着昨天的工作干,唯一的办法是每天早上把昨天的完整工作日志重新念一遍给他听。

用公式看清楚“没有记忆”到底是什么意思

设大语言模型为一个函数 f,它只做一件事:接收一段文字,返回一段文字。当你和它进行第 n 轮对话时,实际发生的事情是:

r n = f ( S , m 1 , r 1 , m 2 , r 2 , … , m n − 1 , r n − 1 , m n ) r_n = f(S, m_1, r_1, m_2, r_2, \ldots, m_{n-1}, r_{n-1}, m_n) rn=f(S,m1,r1,m2,r2,,mn1,rn1,mn)

符号 含义 通俗解释
f 大模型本身 那台只会“接一段、续一段”的机器
S 系统提示词 每次都要重发的“开场白”
m_k k 轮用户说的话 你打的字
r_k k 轮模型的回复 模型上一次吐出来的文字
n 轮数 现在聊到第几个来回

关键点解读:

  1. 右边括号里有什么?n 轮的输入,包含了前面所有轮的输入和输出。这就是“没有记忆”的精确写法——模型并不是“想起来”你之前说了什么,而是你的程序把之前的全部对话历史又重新塞进了这次请求里。

  2. 模型自己存了什么? 什么都不存。它只是收到了括号里的那一长串文字,然后根据这段文字续写出 r_n

  3. 所谓“记忆”,是谁在维护? 括号里的那一长串东西——也就是全部的对话历史——是你的程序负责保存和重发的,不是模型。

一个常见的误解

很多人以为“模型能记住 128K 上下文”的意思是“模型自己存了 128K 的记忆”。不是。 那个数字是单次输入的长度上限——是每次你能往括号里塞多少东西的上限。上限之内的东西,仍然要你每次原样重发。

这个公式的后果

假设每一轮对话新增的内容量差不多是常数 c,因为每轮都要把之前的全部重发一遍,那么聊到第 n 轮为止,你累计发送的总量是:

T ( n ) = c ⋅ n ( n + 1 ) 2 T(n) = c \cdot \frac{n(n+1)}{2} T(n)=c2n(n+1)

关键是 n 2 n^2 n2轮数翻一倍,花的钱和 token 量变四倍。 10 轮对话的成本是 1 轮的 55 倍,而不是 10 倍。

这就是为什么 Agent 跑长任务时账单会突然失控,也是为什么所有成熟的 Agent 框架都有一整章专门讲“怎么压缩历史”——因为不压,程序就直接报错了。

那 Agent 是什么?

Agent(智能体) 是一个能自己循环调用模型、并且能真的动手干活(读写文件、执行命令、访问网络)的程序。

一句话总结:模型是“大脑”,Agent 是“大脑 + 手脚 + 循环”。模型只会说,Agent 真的干。


二、Agent 是怎么工作的?二十行代码说清楚

把 Agent 拆到最简,它就是一个循环,核心就四步:

  1. 把整段对话发给模型
  2. 如果模型说要调用工具,就执行它
  3. 把执行结果塞回对话
  4. 重复,直到模型不再要工具

用代码写出来,核心就二十行:

const TOOLS = {
  read: (args) => fs.readFileSync(args.path, 'utf8'),
  write: (args) => { fs.writeFileSync(args.path, args.text); return 'ok' },
}

const messages = [{ role: 'user', text: '把 report.txt 的第一行念给我听' }]

while (true) {
  // ① 整段对话重发——因为模型没有记忆
  const reply = await callModel(messages, schemasOf(TOOLS))
  messages.push(reply)
  
  // ② 模型不再要工具了,收工
  if (!reply.toolCalls || reply.toolCalls.length === 0) break
  
  // ③ 逐个执行——这是唯一真正改变世界的地方
  for (const call of reply.toolCalls) {
    let out, isError = false
    try {
      out = String(await TOOLS[call.name](JSON.parse(call.arguments)))
    } catch (e) {
      out = String(e); isError = true
    }
    // ④ 结果变成消息接回去
    messages.push({ role: 'tool', toolCallId: call.id, text: out, isError })
  }
}

这段代码真的能跑,而且它真的是一个 Agent——不是玩具,是同一个东西的最小形态。


三、工具调用:模型“动手”的真相

很多人第一次听到“模型可以调用工具”,脑子里浮现的是:模型内部长了一双手,能伸出来碰文件。这是错的,而且错得很重要。

真实的机制是这样的:

  1. 你在发请求时,附上一张工具清单:每个工具叫什么、干什么用、需要什么参数
  2. 模型读完,如果觉得该动手,就吐出一段结构化文字,大意是“我要调用 read_file,参数是 {path: 'report.txt'}
  3. 你的程序解析这段文字,真的去打开文件、读出来——这一步纯粹是普通编程,一点 AI 都没有
  4. 你的程序把读到的内容包成新消息,接在对话后面,整段再发一次给模型

模型只是“说”了要干什么,真正动手的是你的程序。

所以,工具能干什么,完全取决于你的程序允许它干什么。模型说“删除整个磁盘”和说“今天天气不错”,在传输层面是完全一样的东西——都是一段文字。会不会真删,取决于你的代码里有没有写那段删除逻辑、有没有做权限控制。

这就是为什么 Agent 的安全和审批机制,从头到尾没有 AI 参与——它全是普通程序的活儿。


四、状态机:为什么 Agent 不会“乱成一锅粥”

上一节我们看了二十行的最小 Agent,它在一个完美的、没人打断、不会出错的世界里能跑。但真实世界不是这样——用户会中途按取消,网络会断,工具会超时,依赖的服务可能还没启动。

如果你用一堆布尔标记来控制这一切,很快就会陷入混乱。

布尔标记的陷阱:16 种组合里只有 5 种合法

假设你要管理一个工具调用的状态,直觉上你可能会写:

let isLoading = false      // 正在执行中?
let isDone = false         // 已经完成了?
let hasError = false       // 出错了?
let isCancelled = false    // 被用户取消了?

这四个布尔值有 2 4 = 16 2^4 = 16 24=16 种组合。但其中合法的可能只有这几种:

isLoading isDone hasError isCancelled 合法?
false false false false ✅ 初始状态
true false false false ✅ 执行中
false true false false ✅ 完成
false false true false ✅ 出错
false false false true ✅ 已取消
true false true false ❌ 既在执行又出错
true false false true ❌ 既在执行又被取消
false true false true ❌ 既完成又被取消
… 还有 8 种 ❌ 全是非法状态

所有那种“怎么会同时又在加载又已经出错”的诡异 Bug,都出在这 11 种不该出现但代码没拦住的状态里。

更麻烦的是:Bug 复现不了。因为状态组合取决于网络延迟、磁盘速度、用户点击的精确时机——这些完全不受控的因素。

状态机的解法:从 16 种压到 5 种

状态机的定义简单到有点可疑:一样东西在任何时刻处于有限个状态中的某一个,并且只能沿着规定好的箭头从一个状态转到另一个。

把上面的工具调用画成状态机就是:

                    ┌──────────────────────────────────────┐
                    │                                      ▼
              ┌─────────┐   用户点击    ┌─────────┐   完成   ┌─────────┐
              │  空闲   │ ──────────► │  执行中  │ ──────► │  已完成  │
              └─────────┘             └─────────┘         └─────────┘
                    │                    │  超时              │
                    │  取消              ▼                    │
                    │              ┌─────────┐               │
                    └────────────► │  已取消  │ ◄─────────────┘
                                   └─────────┘
                                         ▲
                                         │ 出错
                                   ┌─────────┐
                                   │  已失败  │
                                   └─────────┘

合法的转移只有:

  • 空闲 → 执行中(用户点击)
  • 执行中 → 已完成(顺利结束)
  • 执行中 → 已失败(报错)
  • 执行中 → 已取消(用户中途取消)
  • 空闲 → 已取消(用户还没开始就取消)

非法转移在类型层面就表达不出来: 代码里根本写不出“从已完成跳到执行中”或“同时处于执行中和已失败”。不是靠运行时检查,而是在设计阶段就被排除掉了

真实案例:DeepSeek Harness 的 Fiber 六状态

DeepSeek Harness 底层用了一个叫 Cordis 的框架来管理所有插件。每个已加载的插件实例叫 Fiber,它的状态在源码里明明白白写着:

export const enum FiberState {
  PENDING,     // 在等依赖,还没开始
  LOADING,     // 正在启动
  ACTIVE,      // 跑着呢
  FAILED,      // 起崩了
  DISPOSED,    // 被拿掉了,回不来了
  UNLOADING,   // 正在拆
}

PENDINGFAILED 是两件完全不同的事:

  • PENDING:我还没开始,因为条件没满足(比如依赖的服务还没启动),条件满足了我就能自动起来
  • FAILED:我试过了,我起不来,需要人去修

如果只用一个布尔 isBroken,这两件事就混在一起——你永远搞不清该去安装一个缺失的依赖,还是该去修一段崩溃的代码。

状态之间的转移也不是任意的:

PENDING → LOADING → ACTIVE
                 ↘ FAILED
        UNLOADING → DISPOSED
  • PENDING 只能去 LOADING(依赖就绪)或 UNLOADING(被强制卸载),不能直接去 ACTIVE
  • LOADING 可能去 ACTIVE(启动成功),也可能去 FAILED(启动过程崩了)
  • ACTIVE 只能去 UNLOADING(正常卸载),不能直接变 FAILED
  • FAILEDDISPOSED 没有出路——死透了,再也回不来

状态转移的箭头,就是你对系统行为的全部承诺。

红绿灯的类比,以及它在哪里失效

打个比方: 状态机就像红绿灯。红、黄、绿三个状态,箭头是红→绿→黄→红,不能红直接跳黄,也不能同时亮两个灯。

类比失效处(也是理解的关键):

  1. 红绿灯是时间驱动的——到点就变。程序里的状态转移是事件驱动的——依赖就绪了才从 PENDING 走到 LOADING,可能是 1 毫秒后,也可能永远不会。
  2. 红绿灯只有一条环路,Fiber 的箭头有分叉——LOADING 之后可能去 ACTIVE,也可能去 FAILED,这是两种完全不同的结果。
  3. 红绿灯没有“出错”状态——灯丝烧了它不会自己知道。程序里状态机必须把“执行失败”作为一等公民状态,而不是“啥都不亮”这种未定义情况。

Agent 里哪些地方用了状态机?

把你那二十行的最小 Agent 放到真实世界里,每个环节都需要状态机:

环节 状态机管什么
工具调用 空闲 → 执行中 → 已完成 / 已失败 / 已取消
会话生命周期 初始化 → 就绪 → 运行中 → 压缩中 → 结束
插件加载 PENDING → LOADING → ACTIVE / FAILED
用户审批 等待审批 → 已批准 → 已执行 / 已拒绝
重试策略 首次尝试 → 重试中(带计数)→ 成功 / 失败

Agent 循环本身也是一个状态机:

                    ┌─────────────────────────────────────┐
                    │                                     │
                    ▼                                     │
              ┌──────────┐   模型回复含    ┌──────────┐   执行完成   ┌──────────┐
              │  等待模型 │ ────────────► │  执行工具  │ ─────────► │  检查结果 │
              └──────────┘   工具调用      └──────────┘             └──────────┘
                    │                          │ 出错                   │
                    │ 模型回复纯文字            ▼                        │ 还要继续
                    ▼                    ┌──────────┐                   │
              ┌──────────┐              │  错误处理 │ ◄─────────────────┘
              │  结束    │              └──────────┘
              └──────────┘

有了状态机,你才能明确回答三个问题:

  • 当前在干什么?(当前状态)
  • 接下来可能发生什么?(允许的转移)
  • 发生意外时怎么办?(错误状态和转移)

没有状态机,你的 Agent 就是一个“不知道自己在哪一步、也不知道下一步该去哪”的混乱程序——而那些“偶尔复现不了”的 Bug,就是它混乱的证明。


最后,回答开头那个问题

别人说“我写了个 Agent”,他到底写了什么?

他写的是一个循环

  1. 把对话塞给模型
  2. 解析模型说要干什么
  3. 真的去干
  4. 把结果塞回去
  5. 再问模型下一步

模型只负责“说”,剩下全是普通程序的活儿——而恰恰是这些普通程序的活儿,撑起了像 DeepSeek Harness 这样的框架整整 21.2 万行代码

你那二十行的最小 Agent 能跑,但它在真实世界里会遇到:对话太长塞不下、工具执行超时、并发冲突、权限审批、断点续跑、回滚重试……这些问题一个都不需要 AI,但一个都躲不掉。

Agent 最难的部分,从来不在 AI。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐