Skip to content

入口点架构

入口点是应用程序初始化契约的建立之处。与其将 main.go 视为一系列函数调用,不如将其视为依赖图组装——每个组件都按照其依赖关系以正确的顺序接入系统,确保在接收用户输入之前系统已完全可用。

初始化依赖图

┌─────────────────────────────────────────────────────────────────────┐
│                     初始化依赖图                                    │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌──────────────┐                                                  │
│  │   日志器     │  (基础层 —— 所有组件在初始化时都使用日志)        │
│  └──────────────┘                                                  │
│         │                                                          │
│         ▼                                                          │
│  ┌──────────────┐                                                  │
│  │   信号处理   │  (优雅关闭契约)                                  │
│  └──────────────┘                                                  │
│         │                                                          │
│         ▼                                                          │
│  ┌──────────────┐    ┌──────────────┐                             │
│  │    配置      │───▶│  API 客户端  │  (需要凭据)                 │
│  └──────────────┘    └──────────────┘                             │
│         │                  │                                       │
│         │                  ▼                                       │
│         │          ┌──────────────┐                               │
│         └─────────▶│  工具注册表   │  (需要工作目录)              │
│                     └──────────────┘                               │
│                            │                                        │
│                            ▼                                       │
│                     ┌──────────────┐    ┌──────────────┐          │
│                     │  权限策略    │◀───│    代理      │          │
│                     │              │    │              │          │
│                     └──────────────┘    └──────────────┘          │
│                            │                     │                 │
│                            └─────────────────────┘                 │
│                                              ▼                     │
│                                    ┌──────────────┐                │
│                                    │    REPL      │  (阻塞)        │
│                                    └──────────────┘                │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

为什么这个顺序很重要

初始化序列并非任意——每个步骤都为后续步骤建立前提条件:

1. 日志器优先 —— 基础层

日志器必须在其他任何组件之前初始化,因为每个后续组件在其初始化期间都会记录日志。没有日志目标,配置加载、客户端创建或工具注册期间的错误将无处可去。

这遵循了控制反转原则:应用程序提供一个全局日志器,而不是每个组件创建自己的日志器。这使得:

  • 所有组件的日志格式一致
  • 日志级别集中控制
  • 轻松重定向输出(文件、stderr、syslog)

2. 信号 —— 关闭契约

信号处理程序很早就注册,以建立优雅关闭契约。应用程序承诺任何信号(SIGINT、SIGTERM)都将导致干净的关闭并正确记录——没有孤儿进程,没有丢失状态。

信号处理程序不需要其他组件准备就绪;它只需要日志器。这种最小依赖允许关闭即使在初始化中途失败也能正常工作。

3. 配置 → 客户端 → 工具 —— 依赖链

  • 配置必须在 API 客户端创建之前加载(需要凭据)
  • API 客户端必须在代理实例化之前存在
  • 但在工具和配置之间存在一个微妙的顺序:
    • 工具注册表必须在工具注册之前存在(它充当容器)
    • 配置中的工作目录必须传递给工具(特别是 Bash 工具需要知道在哪里执行命令)

这创建了一个狭窄的窗口:配置加载 → 创建注册表 → 使用工作目录注册工具。跳过此顺序会导致工具在错误的目录中执行或无法找到其依赖项。

4. 权限策略 —— 安全边界

权限策略在所有其他组件之后创建,因为它需要知道存在哪些工具来做出访问决策。策略查询工具注册表以确定哪些工具需要权限以及用户已批准什么。

5. 代理 —— 组合根

代理是 DDD 术语中的组合根——它是所有依赖项组装成驱动应用程序的单个对象的地方。此时:

  • API 客户端已准备好发出请求
  • 工具注册表包含所有可用工具
  • 权限策略知道适用什么限制
  • 系统提示定义了代理的角色

代理不创建其依赖项;它接收它们。这是构造函数注入,最纯粹的依赖注入形式。

6. REPL —— 端点

REPL 阻塞,等待用户输入。它接收完全配置的代理作为依赖。这遵循了应用控制器模式——REPL 将用户命令转换为对代理的方法调用。

依赖注入的优势

初始化序列展示了依赖注入的三个关键优势:

1. 可测试性

每个组件都可以通过提供模拟依赖项进行隔离测试:

go
// 生产环境:真实客户端
agent := agent.NewAgent(realClient, registry, policy, prompt, model)

// 测试:返回可预测响应的模拟客户端
agent := agent.NewAgent(mockClient, registry, policy, prompt, model)

代理不知道区别——它依赖 APIClient 接口,而非具体实现。

2. 模块化

组件可以交换而不改变系统的其他部分。权限策略可以替换(例如,用于不同安全级别),API 客户端可以交换(例如,用于测试),工具可以添加或删除——所有这些都无需修改代理。

3. 明确的依赖项

构造函数签名明确记录了代理需要什么:

go
func NewAgent(
    client APIClient,
    registry *Registry,
    policy *Policy,
    systemPrompt string,
    model string,
) *Agent

没有隐藏状态,没有全局变量,没有意外的初始化顺序。编译器强制执行依赖图

错误处理策略 —— 快速失败

初始化序列遵循快速失败原则:

go
if err != nil {
    logger.Error("Failed to load configuration", "error", err)
    os.Exit(1)
}

为什么快速失败?

  • 诊断:早期错误更容易诊断——堆栈跟踪直接指向问题
  • 恢复:立即退出比以部分初始化状态继续更好
  • 用户体验:描述性错误消息告诉用户具体出了什么问题以及如何修复

每个初始化步骤都验证其前提条件,如果出现问题则退出并显示清晰的错误消息。没有重试逻辑,没有回退行为——系统要么正确启动,要么不能。

信号处理 —— 优雅降级

信号处理程序按设计保持最小化:

go
go func() {
    sig := <-sigChan
    logger.Info("Received signal, shutting down", "signal", sig.String())
    logger.Info("Shutdown complete")
    os.Exit(0)
}()

为什么不更复杂?

  • REPL 处理用户级取消(Ctrl+C 取消当前轮次)
  • 信号处理程序处理系统级终止
  • 没有复杂的状态需要保存——每轮后会话都会被保存
  • 应用程序是短命的(运行一次,执行一个任务,退出)

这是有意的简单——添加更复杂的关闭逻辑对于此用例来说会引入复杂性而没有收益。

使用的设计模式

注册表模式 —— 工具注册表

工具注册表使用注册表模式为所有可用工具提供集中容器:

go
registry := tool.NewRegistry()

关键特性:

  • 延迟查找:工具在执行时按名称检索,而非注册时
  • 线程安全:使用 sync.RWMutex 进行并发读取和安全写入
  • 可扩展:MCP 工具可以在运行时添加,而无需修改现有代码

注册表解耦了工具定义(API 看到的)与工具执行(调用时发生的事)。代理不需要知道工具是内置的还是来自 MCP 服务器。

策略模式 —— 权限系统

权限系统使用策略模式进行访问决策:

go
policy := permission.NewPolicy(permission.WorkspaceWrite)

关键特性:

  • 声明式:策略表达规则,而非实现
  • 可组合:可以组合多个策略(尽管目前只使用一个)
  • 运行时评估:策略检查特定工具和输入,而不仅仅是工具名称

权限策略是策略模式的一种形式——行为(允许/拒绝/询问)可以根据配置变化而不改变使用它的代码。

工厂模式 —— 代理创建

代理构造函数充当工厂,用所有依赖项组装代理:

go
agentInstance := agent.NewAgent(client, registry, policy, systemPrompt, model)

这也是构建器模式语义的一个例子——每个依赖项都是明确必需的,使构造自我记录且不可能出错(编译错误而非运行时错误)。

架构总结

原则实现
依赖图初始化顺序尊重组件依赖
组合根代理是所有依赖项汇合的单一位置
快速失败错误被及早捕获并带有描述性消息
明确依赖构造函数注入使依赖可见
注册表模式具有运行时查找的集中式工具容器
策略模式在执行时评估的声明式权限规则

理解这个初始化序列是理解整个系统的基础——它是建立依赖契约的地方,所有后续操作都建立在此基础上。


基于 MIT 许可证发布