Agent 项目的目录结构设计:一个标准模板,拿来就用
上周我把 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 调用失败了怎么办?指数退避、熔断器、模型降级。
更多推荐



所有评论(0)