上周我把 daily-report-agent 的代码给一个朋友看,他第一句话是:“你这目录乱七八糟的,main.go 直接放根目录,配置散在各处,我想改个 Prompt 都不知道在哪。”

他说得对。

Agent 项目跟普通 Web 服务不一样——它要多一层"大脑"。LLM 调用、Prompt 管理、Tool 注册、记忆存储、成本核算,这些东西搅在一起,不设计好目录结构,写到第三周你自己都不敢改。

这篇给你一个经过验证的标准模板。不是"最佳实践"那种虚的,是我重写了三版之后稳定下来的结构。


先看全景

agent-project/
├── cmd/
│   └── agentd/
│       └── main.go              # 唯一入口
├── internal/
│   ├── agent/
│   │   ├── agent.go             # Agent 核心循环
│   │   ├── tool_registry.go     # Tool 注册与发现
│   │   └── memory.go            # 短期/长期记忆管理
│   ├── llm/
│   │   ├── client.go            # LLM 调用抽象
│   │   ├── retry.go             # 重试策略
│   │   └── cost.go              # Token 成本计算
│   ├── tool/
│   │   ├── tool.go              # Tool 接口定义
│   │   ├── file_reader.go       # 示例 Tool
│   │   └── web_search.go        # 示例 Tool
│   ├── config/
│   │   └── config.go            # 统一配置
│   ├── middleware/
│   │   ├── logging.go           # 日志中间件
│   │   ├── ratelimit.go         # 速率限制
│   │   └── metrics.go           # 监控指标
│   └── store/
│       ├── memory.go            # 内存存储(开发用)
│       └── redis.go             # Redis 存储(生产用)
├── pkg/
│   └── apischema/
│       └── types.go             # 对外共享的类型定义
├── configs/
│   ├── config.yaml              # 默认配置
│   └── prompts/
│       └── system_prompt.txt    # Prompt 模板文件
├── deploy/
│   ├── Dockerfile
│   └── docker-compose.yml
├── go.mod
├── go.sum
└── Makefile

核心设计决策(每一个都是有原因的)

1. cmd/agentd/main.go —— 唯一入口,只做三件事

很多 Go 项目把 main.go 放根目录。没问题,但 Agent 项目启动时要加载的东西多——配置、Prompt、Tool 注册、中间件链——堆在根目录会很难看。

cmd/agentd/main.go 只做三件事:

package main

import (
    "log"
    "os"
    "os/signal"
    "syscall"

    "agent-project/internal/agent"
    "agent-project/internal/config"
    "agent-project/internal/llm"
    "agent-project/internal/middleware"
    "agent-project/internal/store"
    "agent-project/internal/tool"
)

func main() {
    // 1. 加载配置
    cfg, err := config.Load("configs/config.yaml")
    if err != nil {
        log.Fatalf("加载配置失败: %v", err)
    }

    // 2. 组装依赖
    llmClient := llm.NewClient(cfg.LLM)
    llmClient = middleware.Logging(llmClient)       // 包一层日志
    llmClient = middleware.RateLimit(llmClient, 10) // 包一层限流
    llmClient = middleware.Metrics(llmClient)       // 包一层监控

    memStore := store.NewRedisStore(cfg.Redis)
    tools := tool.RegisterAll()

    ag := agent.New(agent.Config{
        Client: llmClient,
        Memory: memStore,
        Tools:  tools,
    })

    // 3. 启动 + 优雅关闭
    go func() {
        if err := ag.Start(); err != nil {
            log.Fatalf("Agent 启动失败: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    ag.Shutdown()
}

这三件事就是所有 Agent 项目的骨架。你每次开新项目,复制这个 main.go,改配置和 Tool 注册就行。

2. internal/agent/ —— Agent 核心循环,不关心具体实现

这是整个项目的"发动机"。它只管一件事:循环执行"思考→行动→观察→思考→…"。

// internal/agent/agent.go
package agent

import (
    "context"
    "fmt"
)

// Client 是 LLM 调用的抽象,不依赖具体实现
type Client interface {
    Chat(ctx context.Context, messages []Message) (*Response, error)
}

type Tool interface {
    Name() string
    Description() string
    Execute(ctx context.Context, input string) (string, error)
}

type Agent struct {
    client Client
    memory Memory
    tools  map[string]Tool
    maxIterations int
}

func New(cfg Config) *Agent {
    toolMap := make(map[string]Tool)
    for _, t := range cfg.Tools {
        toolMap[t.Name()] = t
    }
    return &Agent{
        client:        cfg.Client,
        memory:        cfg.Memory,
        tools:         toolMap,
        maxIterations: 10,
    }
}

func (a *Agent) Run(ctx context.Context, task string) (string, error) {
    // 加载历史记忆
    history, _ := a.memory.Load(ctx)

    messages := append(history, Message{Role: "user", Content: task})

    for i := 0; i < a.maxIterations; i++ {
        resp, err := a.client.Chat(ctx, messages)
        if err != nil {
            return "", fmt.Errorf("第 %d 轮调用失败: %w", i+1, err)
        }

        // 如果需要调用 Tool
        if resp.ToolCall != nil {
            tool, ok := a.tools[resp.ToolCall.Name]
            if !ok {
                return "", fmt.Errorf("未知 Tool: %s", resp.ToolCall.Name)
            }
            toolResult, err := tool.Execute(ctx, resp.ToolCall.Input)
            if err != nil {
                toolResult = fmt.Sprintf("Tool 执行失败: %v", err)
            }

            // 把 Tool 结果追加到对话
            messages = append(messages,
                Message{Role: "assistant", Content: resp.Text, ToolCall: resp.ToolCall},
                Message{Role: "tool", Content: toolResult, ToolCallID: resp.ToolCall.ID},
            )

            // 保存记忆
            a.memory.Save(ctx, messages)
            continue
        }

        // 没有 Tool 调用,说明任务完成
        a.memory.Save(ctx, messages)
        return resp.Text, nil
    }

    return "", fmt.Errorf("超过最大迭代次数 %d", a.maxIterations)
}

注意这个 Agent 结构体——它不知道 Client 是 DeepSeek 还是 Ollama,不知道 Memory 是 Redis 还是内存。依赖全部通过接口注入。这意味着你可以在测试时注入 Mock,在生产时注入真实实现。

3. internal/llm/ —— LLM 调用层,可替换

// internal/llm/client.go
package llm

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
)

type Config struct {
    BaseURL string `yaml:"base_url"`
    APIKey  string `yaml:"api_key"`
    Model   string `yaml:"model"`
}

type Client struct {
    cfg  Config
    http *http.Client
}

func NewClient(cfg Config) *Client {
    return &Client{cfg: cfg, http: &http.Client{}}
}

func (c *Client) Chat(ctx context.Context, messages []Message) (*Response, error) {
    body := map[string]interface{}{
        "model":    c.cfg.Model,
        "messages": messages,
    }
    jsonBody, _ := json.Marshal(body)
    req, _ := http.NewRequestWithContext(
        ctx, "POST",
        c.cfg.BaseURL+"/v1/messages",
        bytes.NewReader(jsonBody),
    )
    req.Header.Set("x-api-key", c.cfg.APIKey)
    req.Header.Set("anthropic-version", "2023-06-01")
    req.Header.Set("Content-Type", "application/json")

    resp, err := c.http.Do(req)
    if err != nil {
        return nil, fmt.Errorf("LLM 请求失败: %w", err)
    }
    defer resp.Body.Close()

    var result Response
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, fmt.Errorf("解析响应失败: %w", err)
    }
    return &result, nil
}

4. 中间件链 —— 不侵入业务代码

这是这个模板里我最满意的设计。通过接口包装,给 LLM 客户端叠加日志、限流、监控:

// internal/middleware/logging.go
package middleware

import (
    "context"
    "log"
    "time"

    "agent-project/internal/agent"
)

type loggingClient struct {
    next agent.Client
}

func Logging(next agent.Client) agent.Client {
    return &loggingClient{next: next}
}

func (l *loggingClient) Chat(ctx context.Context, messages []Message) (*Response, error) {
    start := time.Now()
    resp, err := l.next.Chat(ctx, messages)
    elapsed := time.Since(start)

    if err != nil {
        log.Printf("[LLM] 调用失败 | 耗时=%v | 错误=%v", elapsed, err)
        return nil, err
    }

    log.Printf("[LLM] 调用成功 | 耗时=%v | tokens_in=%d | tokens_out=%d",
        elapsed, resp.Usage.InputTokens, resp.Usage.OutputTokens)
    return resp, nil
}

同样的模式可以叠加 RateLimit(下一篇文章讲)、Metrics(再下一篇讲)。


配置文件管理

用 YAML 而不是环境变量做默认值,环境变量做覆盖。这是生产环境的通用做法:

# configs/config.yaml
llm:
  base_url: "https://api.deepseek.com/anthropic"
  model: "deepseek-v4-pro"

agent:
  max_iterations: 10
  temperature: 0.1

redis:
  addr: "localhost:6379"

server:
  port: 8080
  read_timeout: 30s

API Key 这类敏感信息只走环境变量:

func Load(path string) (*Config, error) {
    cfg := &Config{}
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    yaml.Unmarshal(data, cfg)

    // 环境变量覆盖
    if key := os.Getenv("LLM_API_KEY"); key != "" {
        cfg.LLM.APIKey = key
    }
    return cfg, nil
}

这个模板怎么用

# 克隆模板
git clone https://github.com/lobster-bujiaban/agent-template.git my-agent
cd my-agent

# 改配置
cp configs/config.yaml.example configs/config.yaml
# 编辑 config.yaml,填入你的 API Key

# 添加你的 Tool
# 在 internal/tool/ 下新建文件,实现 Tool 接口

# 跑
go run cmd/agentd/main.go

目录搭好了。下一篇聊 Agent 跑起来后第一个让你头疼的问题——AI 调用失败了怎么办?指数退避、熔断器、模型降级。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐