Go 构建 AI 代理实战——基于 Go 的 LLM Agent 框架设计与实现
Go 构建 AI 代理实战——基于 Go 的 LLM Agent 框架设计与实现
适读人群:有 Go 基础、想自己实现 AI Agent 而不依赖 LangChain 的开发者 | 阅读时长:约 20 分钟 | 核心价值:从零设计一个轻量级 Go LLM Agent 框架,理解 ReAct 循环核心机制
大概六个月前,一个做电商运营 SaaS 的团队来咨询我,他们想做一个"自动化运营助手"——输入一个运营目标,AI 自动分析数据、生成方案、发起审批流程。
他们一开始想直接用 Python 的 LangChain。我问了一个问题:你们的主业务是 Go 吗?他们说是的。我说那 LangChain 在生产环境的调试成本会让你后悔的——抽象层太厚,出了问题找不到根。而且他们对延迟有要求,LangChain 的每个 chain step 都有不小的 Python overhead。
最后他们选择用 Go 自己实现一个轻量级 Agent。这篇文章把那个框架的核心设计讲清楚。
Agent 的本质:ReAct 循环
LLM Agent 的核心是 ReAct(Reasoning + Acting)循环:
用户输入
↓
LLM 思考(Thought)
↓
决定行动(Action)→ 执行工具(Tool)→ 获取观察(Observation)
↓
再次思考 → 再次行动 ... 或 给出最终答案这个循环可以用一个状态机来实现。理解了这个,整个框架就清楚了。
框架结构
agent/
├── agent.go # 核心 Agent,ReAct 循环
├── tool.go # Tool 接口和注册表
├── memory.go # 对话历史和工作记忆
├── llm.go # LLM 抽象接口
├── prompt.go # Prompt 模板
└── tools/
├── search.go # 搜索工具
├── calculator.go # 计算工具
└── http.go # HTTP 请求工具Tool 接口设计
package agent
import (
"context"
"encoding/json"
"fmt"
)
// Tool 定义 Agent 可以使用的工具
type Tool interface {
Name() string
Description() string
// InputSchema 返回 JSON Schema,用于告诉 LLM 参数格式
InputSchema() map[string]interface{}
// Execute 执行工具,input 是 JSON 字符串
Execute(ctx context.Context, input json.RawMessage) (string, error)
}
// ToolRegistry 工具注册表
type ToolRegistry struct {
tools map[string]Tool
}
func NewToolRegistry() *ToolRegistry {
return &ToolRegistry{tools: make(map[string]Tool)}
}
func (r *ToolRegistry) Register(t Tool) error {
if _, exists := r.tools[t.Name()]; exists {
return fmt.Errorf("tool %q already registered", t.Name())
}
r.tools[t.Name()] = t
return nil
}
func (r *ToolRegistry) Get(name string) (Tool, bool) {
t, ok := r.tools[name]
return t, ok
}
// ToLLMFormat 把工具列表转换成 LLM 能理解的格式
func (r *ToolRegistry) ToLLMFormat() []map[string]interface{} {
var result []map[string]interface{}
for _, t := range r.tools {
result = append(result, map[string]interface{}{
"name": t.Name(),
"description": t.Description(),
"input_schema": t.InputSchema(),
})
}
return result
}一个实际的工具实现:HTTP 请求工具
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type HTTPTool struct {
httpClient *http.Client
}
func NewHTTPTool() *HTTPTool {
return &HTTPTool{
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (t *HTTPTool) Name() string { return "http_get" }
func (t *HTTPTool) Description() string {
return "向指定 URL 发送 GET 请求,获取返回内容。适合获取网页数据或调用开放 API。"
}
func (t *HTTPTool) InputSchema() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{
"type": "string",
"description": "要请求的完整 URL",
},
"max_bytes": map[string]interface{}{
"type": "integer",
"description": "最多读取的字节数,默认 4096",
},
},
"required": []string{"url"},
}
}
func (t *HTTPTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
var params struct {
URL string `json:"url"`
MaxBytes int `json:"max_bytes"`
}
if err := json.Unmarshal(input, ¶ms); err != nil {
return "", fmt.Errorf("invalid input: %w", err)
}
if params.MaxBytes == 0 {
params.MaxBytes = 4096
}
req, err := http.NewRequestWithContext(ctx, "GET", params.URL, nil)
if err != nil {
return "", err
}
resp, err := t.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(params.MaxBytes)))
if err != nil {
return "", err
}
return fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, string(body)), nil
}核心 Agent:ReAct 循环实现
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
anthropic "github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"os"
)
// StepResult 单步执行结果
type StepResult struct {
Thought string
Action string
ActionInput string
Observation string
FinalAnswer string
IsTerminal bool
}
// AgentConfig Agent 配置
type AgentConfig struct {
MaxSteps int
StepTimeout time.Duration
SystemPrompt string
Verbose bool
}
// Agent 核心结构
type Agent struct {
llmClient *anthropic.Client
registry *ToolRegistry
config AgentConfig
}
func NewAgent(registry *ToolRegistry, config AgentConfig) *Agent {
if config.MaxSteps == 0 {
config.MaxSteps = 15
}
if config.StepTimeout == 0 {
config.StepTimeout = 30 * time.Second
}
return &Agent{
llmClient: anthropic.NewClient(option.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY"))),
registry: registry,
config: config,
}
}
// Run 执行 Agent,返回最终答案
func (a *Agent) Run(ctx context.Context, task string) (string, []StepResult, error) {
messages := []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(task)),
}
// 把工具转换成 Anthropic API 格式
var tools []anthropic.ToolParam
for _, toolDef := range a.registry.ToLLMFormat() {
schema := toolDef["input_schema"].(map[string]interface{})
tools = append(tools, anthropic.ToolParam{
Name: anthropic.F(toolDef["name"].(string)),
Description: anthropic.F(toolDef["description"].(string)),
InputSchema: anthropic.F(anthropic.ToolInputSchemaParam{
Type: anthropic.F(anthropic.ToolInputSchemaTypeObject),
Properties: anthropic.F(schema["properties"]),
}),
})
}
var steps []StepResult
for step := 0; step < a.config.MaxSteps; step++ {
stepCtx, cancel := context.WithTimeout(ctx, a.config.StepTimeout)
resp, err := a.llmClient.Messages.New(stepCtx, anthropic.MessageNewParams{
Model: anthropic.F(anthropic.ModelClaude3_5SonnetLatest),
MaxTokens: anthropic.F(int64(4096)),
System: anthropic.F([]anthropic.TextBlockParam{
{Type: anthropic.F(anthropic.TextBlockParamTypeText), Text: anthropic.F(a.config.SystemPrompt)},
}),
Tools: anthropic.F(tools),
Messages: anthropic.F(messages),
})
cancel()
if err != nil {
return "", steps, fmt.Errorf("step %d LLM error: %w", step, err)
}
// 检查是否终止
if resp.StopReason == anthropic.MessageStopReasonEndTurn {
var finalAnswer strings.Builder
for _, block := range resp.Content {
if tb, ok := block.(anthropic.TextBlock); ok {
finalAnswer.WriteString(tb.Text)
}
}
steps = append(steps, StepResult{
FinalAnswer: finalAnswer.String(),
IsTerminal: true,
})
return finalAnswer.String(), steps, nil
}
// 有工具调用
if resp.StopReason == anthropic.MessageStopReasonToolUse {
messages = append(messages, anthropic.NewAssistantMessage(resp.Content...))
var toolResults []anthropic.MessageParamContentUnion
for _, block := range resp.Content {
toolUse, ok := block.(anthropic.ToolUseBlock)
if !ok {
continue
}
if a.config.Verbose {
fmt.Printf("[Step %d] Tool: %s, Input: %s\n", step+1, toolUse.Name, string(toolUse.Input))
}
tool, exists := a.registry.Get(toolUse.Name)
var observation string
if !exists {
observation = fmt.Sprintf("Error: tool %q not found", toolUse.Name)
} else {
toolCtx, toolCancel := context.WithTimeout(ctx, a.config.StepTimeout)
obs, toolErr := tool.Execute(toolCtx, toolUse.Input)
toolCancel()
if toolErr != nil {
observation = fmt.Sprintf("Error: %v", toolErr)
} else {
observation = obs
}
}
steps = append(steps, StepResult{
Action: toolUse.Name,
ActionInput: string(toolUse.Input),
Observation: observation,
})
toolResults = append(toolResults, anthropic.NewToolResultBlock(toolUse.ID, observation, false))
}
messages = append(messages, anthropic.NewUserMessage(toolResults...))
}
}
return "", steps, fmt.Errorf("max steps (%d) exceeded without final answer", a.config.MaxSteps)
}使用示例
func main() {
registry := agent.NewToolRegistry()
registry.Register(tools.NewHTTPTool())
registry.Register(tools.NewCalculatorTool())
a := agent.NewAgent(registry, agent.AgentConfig{
MaxSteps: 10,
Verbose: true,
SystemPrompt: `你是一个运营数据分析助手。
你可以使用工具获取数据,然后进行分析,给出具体可执行的建议。
每次使用工具前先说明你要做什么,为什么这么做。`,
})
answer, steps, err := a.Run(
context.Background(),
"获取 https://api.example.com/sales/today 的销售数据,计算环比增长率",
)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Printf("执行了 %d 步\n", len(steps))
fmt.Println("最终答案:", answer)
}踩坑实录
踩坑 1:工具输出太长把 context 撑爆
现象:让 Agent 分析一个网页内容,HTTP 工具返回了整个 HTML,3万字直接塞进消息历史,下一步 API 调用报 context 超限。
原因:没有对工具输出做截断。
解法:在工具层做输出截断,超过 2000 字节就截断并加注释 [内容已截断,原始长度: XXX bytes]。对 HTML 场景,最好在工具里做基本的文本提取(去掉 HTML 标签)。
踩坑 2:Agent 陷入工具调用死循环
现象:某个任务里 Agent 反复调用同一个工具,返回同样的结果,却一直不给出最终答案,一直到 MaxSteps 上限。
原因:System Prompt 没有明确告诉 Agent 什么时候应该"放弃并给出力所能及的答案"。
解法:在系统提示词里加入:
如果同一个工具被调用超过3次且结果没有进展,请停止尝试,基于已有信息给出力所能及的答案。
不要追求完美,给出一个有用的部分答案比无限循环要好。踩坑 3:Step Timeout 设得太短导致工具执行被截断
现象:HTTP 工具有时请求慢速 API,StepTimeout 设的 5 秒不够,工具被强制中断,AI 收到空结果,直接说"无法获取数据",但其实数据是可以获取的。
解法:StepTimeout 要区分 LLM 超时和工具执行超时,工具超时可以设得更长(30 秒),LLM 调用保持 30 秒。
框架选型建议
| 场景 | 建议 |
|---|---|
| 快速验证想法 | 直接用 Python LangChain,不要自己造轮子 |
| Go 微服务里嵌入 Agent | 用本文方案,轻量无依赖 |
| 需要复杂工作流(并行、分支) | 考虑 LangGraph 的 Go 版或自己实现 DAG |
| 工具数量超过 20 个 | 考虑 Tool Router,先让 AI 选工具集再执行 |
