入口点架构
入口点是应用程序初始化契约的建立之处。与其将 main.go 视为一系列函数调用,不如将其视为依赖图组装——每个组件都按照其依赖关系以正确的顺序接入系统,确保在接收用户输入之前系统已完全可用。
初始化依赖图
┌─────────────────────────────────────────────────────────────────────┐
│ 初始化依赖图 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ 日志器 │ (基础层 —— 所有组件在初始化时都使用日志) │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 信号处理 │ (优雅关闭契约) │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 配置 │───▶│ API 客户端 │ (需要凭据) │
│ └──────────────┘ └──────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ └─────────▶│ 工具注册表 │ (需要工作目录) │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 权限策略 │◀───│ 代理 │ │
│ │ │ │ │ │
│ └──────────────┘ └──────────────┘ │
│ │ │ │
│ └─────────────────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ REPL │ (阻塞) │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘为什么这个顺序很重要
初始化序列并非任意——每个步骤都为后续步骤建立前提条件:
1. 日志器优先 —— 基础层
日志器必须在其他任何组件之前初始化,因为每个后续组件在其初始化期间都会记录日志。没有日志目标,配置加载、客户端创建或工具注册期间的错误将无处可去。
这遵循了控制反转原则:应用程序提供一个全局日志器,而不是每个组件创建自己的日志器。这使得:
- 所有组件的日志格式一致
- 日志级别集中控制
- 轻松重定向输出(文件、stderr、syslog)
2. 信号 —— 关闭契约
信号处理程序很早就注册,以建立优雅关闭契约。应用程序承诺任何信号(SIGINT、SIGTERM)都将导致干净的关闭并正确记录——没有孤儿进程,没有丢失状态。
信号处理程序不需要其他组件准备就绪;它只需要日志器。这种最小依赖允许关闭即使在初始化中途失败也能正常工作。
3. 配置 → 客户端 → 工具 —— 依赖链
- 配置必须在 API 客户端创建之前加载(需要凭据)
- API 客户端必须在代理实例化之前存在
- 但在工具和配置之间存在一个微妙的顺序:
- 工具注册表必须在工具注册之前存在(它充当容器)
- 配置中的工作目录必须传递给工具(特别是 Bash 工具需要知道在哪里执行命令)
这创建了一个狭窄的窗口:配置加载 → 创建注册表 → 使用工作目录注册工具。跳过此顺序会导致工具在错误的目录中执行或无法找到其依赖项。
4. 权限策略 —— 安全边界
权限策略在所有其他组件之后创建,因为它需要知道存在哪些工具来做出访问决策。策略查询工具注册表以确定哪些工具需要权限以及用户已批准什么。
5. 代理 —— 组合根
代理是 DDD 术语中的组合根——它是所有依赖项组装成驱动应用程序的单个对象的地方。此时:
- API 客户端已准备好发出请求
- 工具注册表包含所有可用工具
- 权限策略知道适用什么限制
- 系统提示定义了代理的角色
代理不创建其依赖项;它接收它们。这是构造函数注入,最纯粹的依赖注入形式。
6. REPL —— 端点
REPL 阻塞,等待用户输入。它接收完全配置的代理作为依赖。这遵循了应用控制器模式——REPL 将用户命令转换为对代理的方法调用。
依赖注入的优势
初始化序列展示了依赖注入的三个关键优势:
1. 可测试性
每个组件都可以通过提供模拟依赖项进行隔离测试:
// 生产环境:真实客户端
agent := agent.NewAgent(realClient, registry, policy, prompt, model)
// 测试:返回可预测响应的模拟客户端
agent := agent.NewAgent(mockClient, registry, policy, prompt, model)代理不知道区别——它依赖 APIClient 接口,而非具体实现。
2. 模块化
组件可以交换而不改变系统的其他部分。权限策略可以替换(例如,用于不同安全级别),API 客户端可以交换(例如,用于测试),工具可以添加或删除——所有这些都无需修改代理。
3. 明确的依赖项
构造函数签名明确记录了代理需要什么:
func NewAgent(
client APIClient,
registry *Registry,
policy *Policy,
systemPrompt string,
model string,
) *Agent没有隐藏状态,没有全局变量,没有意外的初始化顺序。编译器强制执行依赖图。
错误处理策略 —— 快速失败
初始化序列遵循快速失败原则:
if err != nil {
logger.Error("Failed to load configuration", "error", err)
os.Exit(1)
}为什么快速失败?
- 诊断:早期错误更容易诊断——堆栈跟踪直接指向问题
- 恢复:立即退出比以部分初始化状态继续更好
- 用户体验:描述性错误消息告诉用户具体出了什么问题以及如何修复
每个初始化步骤都验证其前提条件,如果出现问题则退出并显示清晰的错误消息。没有重试逻辑,没有回退行为——系统要么正确启动,要么不能。
信号处理 —— 优雅降级
信号处理程序按设计保持最小化:
go func() {
sig := <-sigChan
logger.Info("Received signal, shutting down", "signal", sig.String())
logger.Info("Shutdown complete")
os.Exit(0)
}()为什么不更复杂?
- REPL 处理用户级取消(Ctrl+C 取消当前轮次)
- 信号处理程序处理系统级终止
- 没有复杂的状态需要保存——每轮后会话都会被保存
- 应用程序是短命的(运行一次,执行一个任务,退出)
这是有意的简单——添加更复杂的关闭逻辑对于此用例来说会引入复杂性而没有收益。
使用的设计模式
注册表模式 —— 工具注册表
工具注册表使用注册表模式为所有可用工具提供集中容器:
registry := tool.NewRegistry()关键特性:
- 延迟查找:工具在执行时按名称检索,而非注册时
- 线程安全:使用
sync.RWMutex进行并发读取和安全写入 - 可扩展:MCP 工具可以在运行时添加,而无需修改现有代码
注册表解耦了工具定义(API 看到的)与工具执行(调用时发生的事)。代理不需要知道工具是内置的还是来自 MCP 服务器。
策略模式 —— 权限系统
权限系统使用策略模式进行访问决策:
policy := permission.NewPolicy(permission.WorkspaceWrite)关键特性:
- 声明式:策略表达规则,而非实现
- 可组合:可以组合多个策略(尽管目前只使用一个)
- 运行时评估:策略检查特定工具和输入,而不仅仅是工具名称
权限策略是策略模式的一种形式——行为(允许/拒绝/询问)可以根据配置变化而不改变使用它的代码。
工厂模式 —— 代理创建
代理构造函数充当工厂,用所有依赖项组装代理:
agentInstance := agent.NewAgent(client, registry, policy, systemPrompt, model)这也是构建器模式语义的一个例子——每个依赖项都是明确必需的,使构造自我记录且不可能出错(编译错误而非运行时错误)。
架构总结
| 原则 | 实现 |
|---|---|
| 依赖图 | 初始化顺序尊重组件依赖 |
| 组合根 | 代理是所有依赖项汇合的单一位置 |
| 快速失败 | 错误被及早捕获并带有描述性消息 |
| 明确依赖 | 构造函数注入使依赖可见 |
| 注册表模式 | 具有运行时查找的集中式工具容器 |
| 策略模式 | 在执行时评估的声明式权限规则 |
理解这个初始化序列是理解整个系统的基础——它是建立依赖契约的地方,所有后续操作都建立在此基础上。