0%

用 Golang 从零理解 LLM Agent

本文根据一篇面向 Go 开发者的 Agent 学习材料整理,并结合知识库中的结构化笔记重写。

Agent 和聊天壳的区别

很多人写的其实不是 Agent,只是一个聊天壳。

现在大家一说 Agent,往往容易直接想到“把用户输入发给模型,再把结果流式打印出来”。从体验上看,它已经挺像一个 AI 应用了,但从工程结构上讲,这通常还只是一个聊天壳,不是真正意义上的 Agent。

真正的区别不在于模型多聪明,而在于系统有没有把模型组织成一个能持续推进任务的执行器。

如果只是单轮问答,模型更像一个被动回答问题的接口。你问一句,它答一句,这一轮就结束了。可一旦任务变成“先查信息,再根据结果做判断,再决定下一步”,系统就需要多出几样东西:

  • 它要记得上下文
  • 它要知道自己可以调用哪些工具
  • 它要能在一次回复之后继续推进下一步,而不是立刻结束

也正因为这样,我现在更愿意把 Agent 理解成一个由四个部分组成的最小系统:

  • 大脑:LLM 本身,负责理解和推理
  • 记忆:维护上下文,不让模型每轮都失忆
  • 工具:让模型能访问外部世界
  • 循环:让任务能多步推进,直到完成

如果这四样里只剩第一样,那程序大概率还只是一个会聊天的模型外壳。

Go 接 LLM API 的协议层

用 Go 接大模型 API,其实是在搭一层“协议”。

很多 Go 开发者第一次接 LLM API,会觉得这不就是个 HTTP POST 吗?从表面上看确实如此,但真正稳定可用的实现,重点不是“请求发出去了”,而是你有没有把这层通信整理成一个清晰的协议。

在 Go 里,这件事通常落在几个很具体的点上:

  • structjson tag 定义请求和响应
  • net/http 处理带鉴权的 POST 请求
  • encoding/json 负责序列化和反序列化
  • bufio.Scanner 逐行处理流式返回

这一步最容易被忽略的小坑,反而都是 Go 本身的基础问题。比如结构体字段如果首字母小写,encoding/json 就看不到它,最后你以为自己发了 model 字段,实际请求体里根本没有。再比如如果不设超时、不在响应成功后及时 defer resp.Body.Close(),程序就很容易在网络波动时表现得很不稳定。

所以对 Go 开发者来说,接模型 API 不只是“会发请求”,而是在给大模型应用搭一层协议层。只有这层协议稳定了,后面的 Memory、Tool Calling 和 Agent Loop 才有地方可接。

还有一个很现实的问题是流式输出。很多人第一次实现 SSE,会把注意力放在“怎么实现打字机效果”,但流式能力真正考验的是错误处理:如果中途断网了怎么办?如果某一行 JSON 解不出来怎么办?如果用户半路取消请求怎么办?
这些问题不解决,流式输出只是看起来酷,离好用还差得远。

Agent 的最小状态管理

Memory 不是锦上添花,而是 Agent 最低限度的状态管理。

大模型本身通常是无状态的。也就是说,如果你不把上一轮对话再发回去,它根本不知道你们刚才聊过什么。

这也是为什么很多人以为自己在做 Agent,结果程序一旦多聊两轮就开始“断片”。模型不是突然变笨了,而是系统根本没有帮它维护状态。

最小可用的 Memory,其实并不复杂。对很多 Go 应用来说,一组 []Message 就已经够作为起点:

  • system
  • user
  • assistant
  • 需要时再加 tool

但即便是这么简单的一层,也有几个非常关键的点。

第一,System Prompt 不能轻易丢。
它定义了模型的角色和行为边界。如果你在做滑动窗口截断时只保留最近几轮对话,却把 system 丢了,模型的人设和任务约束就很容易漂。

第二,Memory 和外部数据不是一回事。
很多初学者会把“账单列表”“知识库内容”“对话历史”都统称为 memory,但工程上更清晰的划分通常是:

  • Memory 负责当前任务的上下文状态
  • Tool 或 Retrieval 负责按需拿外部数据

第三,滑动窗口不是可选项。
一旦对话变长,token 成本和模型上下文上限都会成为现实约束。通常更实际的做法,是保留最初的 system,再加最近若干轮关键消息,而不是把所有历史无脑全塞回去。

换句话说,Memory 的本质不是“让模型记住一切”,而是“在成本有限的情况下,把完成当前任务最关键的状态交回给模型”。

Function Calling 与 ReAct Loop

Function Calling 和 ReAct,才是 Agent 真正开始长出“手脚”的地方。

如果说 Memory 解决的是“模型会不会忘”,那 Function Calling 解决的就是“模型会不会做”。

LLM 最大的特点之一,是它很会解释事情,但不会真的去执行事情。你让它查天气、读文件、查数据库、算税,如果不给工具,它只能尽量模仿一个答案。
Function Calling 的价值就在于,它给了模型一种更诚实的表达方式:当它需要外部能力时,不是继续瞎答,而是明确告诉系统“我要调用这个工具,参数是这些”。

对 Go 程序来说,这件事落地下来通常就是三步:

  • 识别模型回复里有没有 tool call
  • 解析函数名和参数
  • 分发到本地实际函数执行

这一步很多人做完之后,会觉得自己已经写完 Agent 了。但严格来说,这还只是“带工具的问答”。因为真正让 Agent 和聊天机器人彻底拉开差距的,是 ReAct 循环。

所谓 ReAct,本质上就是让系统反复经历这几个阶段:

  • 思考
  • 行动
  • 观察
  • 再思考

也就是说,模型不是调用一次工具就结束,而是要把工具结果重新纳入上下文,再决定下一步该做什么。

举个最简单的例子。用户问:“深圳今天下雨吗?如果下雨我就带伞。”
如果模型只是调一次天气工具,然后程序把“下大雨”直接打印给用户,那你只是完成了工具调用。
只有当系统把这次 tool call 和记下来的 observation 一起送回模型,再让模型基于用户原始意图给出“那今天记得带伞”的最终答复,这个循环才真正闭合。

这也是为什么一个工程上更完整的 Agent Loop,通常需要:

  • 保留用户原始问题
  • 保留 assistant 发起 tool call 的记录
  • 保留 tool 返回的结果
  • 设置 maxSteps 之类的上限,避免死循环

所以从工程角度讲,Function Calling 让模型拥有“手”,ReAct Loop 则让它学会“手该什么时候伸出去,以及伸出去之后下一步做什么”。

Go Agent 的入门骨架

如果你准备开始写一个 Go Agent,先把骨架搭对。

我现在越来越觉得,Agent 入门最重要的不是一上来研究多智能体、长期记忆或者复杂规划,而是先把最小骨架搭对。

对一个 Go 开发者来说,一套足够好的起点通常是:

  1. 先把 API 通信层写稳
    结构体、鉴权、超时、错误处理、流式输出,先全部跑顺。

  2. 再把 Memory 补起来
    先做最小版消息历史和滑动窗口,别让模型一轮一失忆。

  3. 再加 Tool Calling
    先支持一两个最小工具,把解析和分发逻辑走通。

  4. 最后再写 Agent Loop
    把“思考 -> 行动 -> 观察”闭成一个真正的多步循环。

这时候你得到的,才不是一个“会调接口的聊天程序”,而是一个真正开始具备任务执行能力的 Agent 雏形。

如果用一句话总结这篇文章,我会这么说:

Agent 不是给聊天机器人多加几个功能点,而是把 LLM、状态、工具和控制流重新组织成一个能完成任务的系统。