把 LLM 塞进聊天室只需二十行胶水代码;但要让它在真实世界里稳定、安全、有个性地活下去,需要的是一整个工程。
本文是对 Saber (rua.plus/saber)的深度解读。Saber 是一个用 Go 1.26 构建的多平台 AI 机器人——它同时栖息于 Matrix 协议与 QQ 官方机器人 API 之上,能进行加密对话、流式应答、调用工具、主动开口,甚至可以为每个聊天室切换不同的“人格“。
但它真正值得读的地方,不在于功能清单有多长,而在于这些功能如何被组织进一个清晰、可测试、有韧性的架构里 。让我们一层层剥开它。
💡 本文的代码块大多可以直接运行 (标记为 ```go runnable)。它们从 Saber 源码中提炼出核心骨架,去掉对 Matrix/LLM 的依赖,只保留设计本身——你可以在浏览器里直接把玩。
一、全景:从 main() 到 Ctrl+C# 一切始于一个异常克制的入口:
func main ( ) {
err := bot. Run ( matrix. BuildInfo {
Version: version, GitCommit: gitCommit,
} )
if err != nil {
if code , ok := bot. IsExitCode ( err ) ; ok { os. Exit ( code ) }
slog. Error ( " 机器人启动失败" , " error" , err)
os. Exit ( 1 )
}
} 没有业务逻辑,没有 os.Exit 散落——main 只负责注入构建期信息和最终的退出码翻译。真正的生命周期被封装在 bot.Run() 里,并遵循一条线性的初始化脉络:
flowchart TD
A["bot.Run()"] --> B["initConfig<br/>CLI · 配置加载 · 日志"]
B --> C["initMatrixClient<br/>连接 · 登录 · 会话持久化"]
C --> D["initCrypto<br/>E2EE / pickle key"]
D --> E["initServices<br/>MCP · Media · AI · Persona · Proactive · Meme · QQ"]
E --> F["setupEventHandlers<br/>消息 · 成员事件 · mention"]
F --> G["startSync<br/>同步循环 · 指数退避重连"]
G --> H["waitForShutdown<br/>并行优雅关闭"]
这条链上每一个环节都遵循同一个约定:返回 error 而非直接 os.Exit 。这不是偶然——它让整个启动流程可以被单元测试完整覆盖(bot_run_test.go 有 21KB 的测试),也让优雅关闭成为可能。
关闭:并行 + 超时竞速# 关闭本身的设计同样干净。shutdown() 用一个 sync.WaitGroup 并行 停止所有服务,再通过 select 在“全部完成“与“超时“之间竞速。下面是这套模式的可运行提炼——任何一个服务卡住,都不会阻塞其他服务,也不会让进程无法退出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package main
import (
"context"
"fmt"
"sync"
"time"
)
// Service 每个服务只需实现 Stop(),即可被并行关闭。
type Service interface {
Name() string
Stop()
}
// parallelShutdown 并行停止所有服务,并在"全部完成"与"超时"之间竞速。
// 任何单个服务卡住,都不阻塞其他服务,也不让进程无法退出。
func parallelShutdown(ctx context.Context, services []Service) {
var wg sync.WaitGroup
done := make(chan struct{})
for _, svc := range services {
wg.Add(1)
go func(s Service) {
defer wg.Done()
fmt.Printf(" 停止 %s ...\n", s.Name())
s.Stop()
fmt.Printf(" %s 已停止\n", s.Name())
}(svc)
}
go func() { wg.Wait(); close(done) }()
select {
case <-done:
fmt.Println("✓ 所有服务已优雅关闭")
case <-ctx.Done():
fmt.Println("⚠ 关闭超时,强制退出")
}
}
type fakeService struct {
name string
d time.Duration
}
func (f *fakeService) Name() string { return f.name }
func (f *fakeService) Stop() { time.Sleep(f.d) }
func main() {
services := []Service{
&fakeService{"AI服务", 80 * time.Millisecond},
&fakeService{"MCP连接", 30 * time.Millisecond},
&fakeService{"主动聊天", 50 * time.Millisecond},
}
fmt.Println("=== 超时充裕:全部优雅关闭 ===")
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
parallelShutdown(ctx, services)
cancel()
fmt.Println("\n=== 超时极短:触发强制退出 ===")
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Millisecond)
parallelShutdown(ctx2, []Service{&fakeService{"慢服务", 200 * time.Millisecond}})
cancel2()
} -- NORMAL -- i 插入 · v 选择 · ⌘↵ 运行
每一个服务都实现了 Stop()/Close(),关闭顺序不阻塞彼此——这正是 Go 的并发原语在系统设计层面的优雅体现。
二、分层架构:深模块与单向依赖# Saber 的代码全部藏在 internal/ 下。这不是 Go 的语法糖游戏,而是一个明确的工程信号:实现细节不对外暴露,跨包协作只走接口 。
依赖方向被严格约束为单向:
bot → {matrix, ai, config, cli, mcp, meme, persona, qq}
ai → {matrix, config, mcp} bot 是唯一的编排者(orchestrator),它知道一切但实现甚少;ai 可以依赖 matrix 与 mcp,但反过来绝不成立。这种约束让每个包都可以被独立理解、独立测试。
flowchart LR
BOT["bot<br/>(编排)"] --> MATRIX["matrix<br/>(协议层)"]
BOT --> AI["ai<br/>(智能层)"]
BOT --> MCP["mcp<br/>(工具层)"]
BOT --> PERSONA["persona"]
BOT --> QQ["qq<br/>(适配)"]
BOT --> MEME["meme"]
AI --> MATRIX
AI --> MCP
QQ --> AI
最有意思的是 ai 与 matrix 之间的关系。ai 需要发送消息,但它不直接持有 mautrix.Client,而是通过一个薄薄的接口:
type MessageSender interface {
SendTextMessage ( ctx, roomID, text ) ( id. EventID , error )
SendTextMessageWithRelation ( ctx, roomID, text, replyTo ) ( id. EventID , error )
}于是 StreamEditor、Service 这些核心逻辑与 Matrix 协议彻底解耦——测试时可以注入一个假发送器,而无需启动真实的 homeserver。这就是“深模块“的力量:接口薄、实现厚,依赖方向单一 。这个思想会在第七章的“人格“里再次出现。
三、AI 服务:会话、流式与工具的三角# internal/ai 是整个项目的心脏——也是最大的包。AI 服务的核心是 Service,一个协调者:它把“对话上下文管理“、“流式响应渲染”、“工具调用循环“三件事编织在一起,但每一件都被抽成了独立、可替换的组件。
3.1 上下文管理:每个房间一段记忆# ContextManager 维护着以 RoomID 为键的对话历史。它做的不是简单的消息队列,而是带“遗忘机制“的记忆:
token 感知的截断 :用 len(text)/0.75 估算 token 数,超过上限时从最旧的消息开始丢弃;
过期清理 :后台 goroutine 周期性扫描,删除超过 expiry_minutes 的消息;
不活跃房间回收 :长期无活动的房间上下文会被整体清除,防止“加入过一千个房间“导致内存膨胀。
这解决了一个真实的运维问题:一个长期运行的 bot 如果对每个它进过的房间都无限保留历史,内存只会单向增长。下面是这套双重截断(条数 + token)的可运行骨架:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package main
import (
"fmt"
"time"
)
type Role string
const (
User Role = "user"
Assistant Role = "assistant"
)
type ChatMessage struct {
Role Role
Content string
Timestamp time.Time
}
// estimateTokens 粗估 token 数:英文约 1 token = 0.75 字符。
// go-openai 社区的常用近似,无需引入 tokenizer 依赖。
func estimateTokens(text string) int {
return int(float64(len(text)) / 0.75)
}
// ContextManager 每个房间一段独立的、会"遗忘"的记忆。
// 两条规则防止内存单向增长:
// 1. MaxMessages:消息条数上限,超过则丢弃最旧的
// 2. MaxTokens:token 上限,超过则从头丢弃直到达标
type ContextManager struct {
MaxMessages int
MaxTokens int
contexts map[string][]ChatMessage
tokenCount map[string]int
}
func NewContextManager(maxMessages, maxTokens int) *ContextManager {
return &ContextManager{
MaxMessages: maxMessages,
MaxTokens: maxTokens,
contexts: make(map[string][]ChatMessage),
tokenCount: make(map[string]int),
}
}
func (cm *ContextManager) AddMessage(room string, role Role, content string) {
msgs := append(cm.contexts[room], ChatMessage{
Role: role, Content: content, Timestamp: time.Now(),
})
cm.tokenCount[room] += estimateTokens(content)
// 规则 1:按条数截断
if cm.MaxMessages > 0 && len(msgs) > cm.MaxMessages {
for _, m := range msgs[:len(msgs)-cm.MaxMessages] {
cm.tokenCount[room] -= estimateTokens(m.Content)
}
msgs = msgs[len(msgs)-cm.MaxMessages:]
}
// 规则 2:按 token 截断(从头丢弃)
for len(msgs) > 0 && cm.tokenCount[room] > cm.MaxTokens {
cm.tokenCount[room] -= estimateTokens(msgs[0].Content)
msgs = msgs[1:]
}
cm.contexts[room] = msgs
}
func (cm *ContextManager) Stats(room string) (msgs, tokens int) {
return len(cm.contexts[room]), cm.tokenCount[room]
}
func main() {
// 每个房间最多保留 3 条消息、240 token
cm := NewContextManager(3, 240)
roomA := "!roomA:matrix.org"
for i := 1; i <= 5; i++ {
cm.AddMessage(roomA, User, fmt.Sprintf("这是第 %d 条消息,稍微长一点以演示截断效果", i))
}
msgs, tokens := cm.Stats(roomA)
fmt.Printf("房间 %s:塞入 5 条,保留 %d 条,%d tokens\n", roomA, msgs, tokens)
for _, m := range cm.contexts[roomA] {
fmt.Printf(" [%s] %s\n", m.Role, m.Content)
}
// 另一个房间互不干扰
roomB := "!roomB:matrix.org"
cm.AddMessage(roomB, Assistant, "你好,我是机器人")
m2, _ := cm.Stats(roomB)
fmt.Printf("\n房间 %s:%d 条消息(与 roomA 隔离)\n", roomB, m2)
} -- NORMAL -- i 插入 · v 选择 · ⌘↵ 运行
3.2 流式响应:边想边说# LLM 的流式输出对聊天体验至关重要——用户不想盯着空白等十秒。Saber 的 StreamEditor 把“流“和“Matrix 消息编辑“缝合在了一起:
type StreamEditor struct {
charThreshold int editInterval time.Duration maxEdits int } 它先发出一条占位消息,然后按“字符阈值 + 时间阈值 + 最大编辑次数“三重节流策略,反复编辑同一条消息,最后用 SendFinal() 落定最终内容。这套节流是必要的——Matrix homeserver 对消息编辑有速率限制,无脑每 chunk 都编辑会被限流甚至封禁。
当模型决定调用工具时,StreamToolHandler 接管:它在流中累积 工具调用的增量参数(流式响应里 arguments 是分多次返回的),等流结束后再把完整的 ToolCall 列表交给上层执行。这是流式 + 工具调用最难的一块,Saber 把它封装得很干净。
3.3 工具调用循环:让 AI 决定下一步# ToolExecutor.ExecuteToolCallingLoop() 是一个有界循环:
发送消息历史 → 模型返回(含/不含 tool_calls)
├─ 无 tool_calls → 返回最终内容
└─ 有 tool_calls → 执行每个工具 → 把结果作为新消息塞回历史 → 回到第一步 循环次数被 MaxIterations(默认 5)硬性限制,防止模型陷入“调工具—不满意—再调工具“的死循环。每一次工具调用的结果都以 tool 角色消息的形式追加到历史,让模型能看到自己上一步的产出。
四、MCP:让 AI 长出双手# 如果说 AI 服务是大脑,那 MCP(Model Context Protocol)就是双手。Saber 的 internal/mcp 实现了一套完整的工具生态系统,而它的设计精华在于工厂模式 带来的可扩展性:
type MCPServerFactory interface {
Create ( ctx, name string , cfg * config. ServerConfig ) ( * mcp. Client , * mcp. ClientSession , error )
Type ( ) string
}三种工厂对应三种连接方式:
类型 工厂 适用场景
builtinBuiltinFactory进程内内存传输,零配置即用
stdioStdioFactory启动子进程,stdin/stdout 走 JSON-RPC
httpHTTPFactory远程 MCP 服务,Bearer 令牌认证
添加一个新的连接类型,只需要再实现一个 MCPServerFactory——Manager 的核心逻辑(工具发现、缓存、路由、调用)完全不用动。这是开闭原则的教科书级示范。下面是这套工厂分发的可运行骨架:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package main
import "fmt"
// Tool 抽象出一个可被 AI 调用的工具。
type Tool struct {
Name string
Description string
}
// ServerFactory 工厂接口:每种连接方式实现一个工厂。
// 添加新连接类型,只需再实现一个 ServerFactory,Manager 核心逻辑不动。
type ServerFactory interface {
Create(name string) (*Tool, error)
Type() string
}
// --- 三种具体工厂,对应三种连接方式 ---
type BuiltinFactory struct{}
func (f *BuiltinFactory) Type() string { return "builtin" }
func (f *BuiltinFactory) Create(name string) (*Tool, error) {
return &Tool{Name: name, Description: "[内存传输] 进程内工具"}, nil
}
type StdioFactory struct{}
func (f *StdioFactory) Type() string { return "stdio" }
func (f *StdioFactory) Create(name string) (*Tool, error) {
return &Tool{Name: name, Description: "[子进程] 通过 stdin/stdout 通信"}, nil
}
type HTTPFactory struct{}
func (f *HTTPFactory) Type() string { return "http" }
func (f *HTTPFactory) Create(name string) (*Tool, error) {
return &Tool{Name: name, Description: "[远程] 通过 HTTP + Bearer 认证"}, nil
}
// Manager 管理所有工厂与工具,按类型路由创建。
type Manager struct {
factories map[string]ServerFactory
}
func NewManager() *Manager {
m := &Manager{factories: make(map[string]ServerFactory)}
for _, f := range []ServerFactory{&BuiltinFactory{}, &StdioFactory{}, &HTTPFactory{}} {
m.factories[f.Type()] = f
}
return m
}
// RegisterFactory 用户可注册自定义工厂,覆盖或新增类型。
func (m *Manager) RegisterFactory(f ServerFactory) {
m.factories[f.Type()] = f
}
func (m *Manager) CreateTool(serverType, name string) (*Tool, error) {
f, ok := m.factories[serverType]
if !ok {
return nil, fmt.Errorf("unknown server type: %s", serverType)
}
return f.Create(name)
}
func main() {
m := NewManager()
for _, st := range []string{"builtin", "stdio", "http"} {
t, _ := m.CreateTool(st, "web_search")
fmt.Printf("%-8s → %s: %s\n", st, t.Name, t.Description)
}
// 扩展:注册一个自定义工厂,Manager 代码零改动(开闭原则)
m.RegisterFactory(&WebSocketFactory{})
t, _ := m.CreateTool("websocket", "custom_tool")
fmt.Printf("%-8s → %s: %s (自定义扩展)\n", "websocket", t.Name, t.Description)
}
// WebSocketFactory 用户自定义的新连接方式——无需改动 Manager。
type WebSocketFactory struct{}
func (f *WebSocketFactory) Type() string { return "websocket" }
func (f *WebSocketFactory) Create(name string) (*Tool, error) {
return &Tool{Name: name, Description: "[自定义] WebSocket 双向通信"}, nil
} -- NORMAL -- i 插入 · v 选择 · ⌘↵ 运行
4.1 内置工具:开箱即用的能力# builtin 类型自带三个工具,全部在进程内运行:
web_search —— 通过 SearXNG 元搜索引擎检索。它内置了一组按可用率排序的公共实例,并支持多实例降级 :第一个实例超时或失败,自动切到下一个。这让搜索在没有任何 API key 的情况下就能工作。
web_fetch —— 抓取网页并转化为模型友好的输入。
js_sandbox —— 用 goja (纯 Go 的 JS 运行时)执行 JavaScript。这是最体现安全意识的一个工具。
4.2 沙箱的安全边界# 让 AI 执行任意代码是危险的。Saber 的 js_sandbox 用多层防御把风险降到最低:
危险 API 禁用 :disableDangerousAPIs() 在运行时里把 require、文件系统、网络相关接口全部摘除;
超时控制 :可配置的 timeout_ms,超时即杀;
输出截断 :max_output_length 防止超大返回撑爆上下文;
内存上限 :max_memory_mb 限制。
而 stdio 类型则用命令白名单 兜底——allowed_commands 默认禁止一切,必须显式放行才能执行外部命令。这是一个“默认安全“的姿态。
4.3 工具调用的护栏# Manager.CallTool() 在真正执行前会过两道闸:
用户上下文校验 :必须通过 WithUserContext() 注入 userID 和 roomID,否则拒绝;
速率限制 :基于 golang.org/x/time/rate 的令牌桶,防止单个用户/房间刷爆工具调用。
此外,ValidateToolInput() 提供了一套不依赖外部库的 JSON Schema 子集校验(type/string/number/array/object/enum),在工具执行前就把畸形参数挡在门外。
五、主动聊天:从被动应答到主动参与# 绝大多数 bot 都是“你问我答“的被动角色。Saber 的 ProactiveManager 把 bot 变成了一个会主动开口 的参与者。这套机制由三个互相独立的触发器驱动:
flowchart TD
subgraph 触发器
S["SilenceTrigger<br/>静默检测<br/>房间 N 分钟无消息"]
T["ScheduleTrigger<br/>定时触发<br/>每天指定 HH:MM"]
N["NewMemberTrigger<br/>新成员欢迎"]
end
S --> DEC
T --> DEC
N --> DEC
DEC["DecisionEngine<br/>收集上下文 → 构建 prompt → 模型裁决"]
DEC -->|"ShouldSpeak=true"| RL["RateLimiter<br/>每日上限 · 最小间隔"]
RL -->|"允许"| SEND["发送主动消息"]
RL -->|"拒绝"| DROP["丢弃"]
DEC -->|"ShouldSpeak=false"| DROP
5.1 决策引擎:让模型自己判断“要不要说话“# DecisionEngine 是这套系统的智能核心。它收集房间的上下文——活跃度(高/中/低)、距上次消息的分钟数、今日消息数、成员数——填入一个提示词模板,然后让一个(通常较小较便宜的)模型返回一个 JSON 决策 :
{ " should_speak" : true , " reason" : " 房间冷清,适合活跃气氛" , " content" : " 嘿,最近怎么样?" } 但在调用模型之前,还有一道纯规则的快速过滤 ShouldPromptAI():
今日已主动发过 ≥5 条 → 不触发(防骚扰);
房间活跃度为“高“ → 不触发(不打扰热闹的对话);
距最后消息 < 阈值 → 不触发(避免插话)。
这些规则成本为零,先于昂贵的模型调用执行。把规则和缓存合在一起看,最能体会这套设计的克制——下面是它的可运行提炼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package main
import "fmt"
// 活动水平
type ActivityLevel string
const (
ActivityLow ActivityLevel = "low"
ActivityMedium ActivityLevel = "medium"
ActivityHigh ActivityLevel = "high"
)
// DecisionContext 房间的活动画像,供"要不要主动开口"判断。
type DecisionContext struct {
RoomName string
ActivityLevel ActivityLevel
MinutesSinceLast int // 距上次用户消息的分钟数
MessagesToday int // 今日已主动发送的消息数
SilenceThresholdMinutes int // 静默阈值:多久没动静才考虑开口
}
// ShouldPromptAI 纯规则预过滤,成本为零,先于昂贵的模型调用执行。
// 返回 false 时直接跳过,连模型都不问。
func (dc *DecisionContext) ShouldPromptAI() bool {
if dc.MessagesToday >= 5 { // 1. 今日已发 5 条 → 别骚扰
return false
}
if dc.ActivityLevel == ActivityHigh { // 2. 房间很热闹 → 别插话
return false
}
threshold := dc.SilenceThresholdMinutes // 3. 还在静默阈值内 → 别打断
if threshold <= 0 {
threshold = 60
}
if dc.MinutesSinceLast < threshold {
return false
}
return true
}
// computeContextHash 把上下文归一化为缓存键。
// 成员数被分桶(1-5/6-20/20+),让"相似画像"命中同一缓存。
func computeContextHash(level ActivityLevel, minutes, msgs, memberCount int) string {
var bucket string
switch {
case memberCount <= 5:
bucket = "1-5"
case memberCount <= 20:
bucket = "6-20"
default:
bucket = "20+"
}
return fmt.Sprintf("%s|%d|%d|%s", level, minutes, msgs, bucket)
}
func main() {
cases := []DecisionContext{
{"深夜闲聊群", ActivityLow, 180, 0, 60}, // 冷清 + 静默久 → 可触发
{"技术讨论组", ActivityHigh, 5, 0, 60}, // 正在热烈讨论 → 不触发
{"刚问完的群", ActivityMedium, 10, 1, 60}, // 才 10 分钟 → 不触发
{"早安打卡群", ActivityLow, 200, 5, 60}, // 今日已发 5 条 → 不触发
}
for _, c := range cases {
fmt.Printf("%-10s 活跃=%-6s 距上次=%4dm 今日=%d/5 → ShouldPrompt=%v\n",
c.RoomName, c.ActivityLevel, c.MinutesSinceLast, c.MessagesToday, c.ShouldPromptAI())
}
// 相同画像 → 相同哈希 → 命中缓存,不重复问模型
fmt.Println("\n缓存键示例(成员 3 与 4 都落入 1-5 桶,命中同一键):")
fmt.Println(" ", computeContextHash(ActivityLow, 180, 0, 3))
fmt.Println(" ", computeContextHash(ActivityLow, 180, 0, 4))
} -- NORMAL -- i 插入 · v 选择 · ⌘↵ 运行
5.2 决策缓存:相似上下文不重复问模型# DecisionCache 是一个常被忽略却很精巧的设计。它对决策上下文计算一个哈希(活跃度 + 分钟数 + 今日消息数 + 触发类型 + 成员数分桶区间 ),以 roomID:hash 为键缓存模型决策,带 TTL 自动过期。
注意上面 computeContextHash 里成员数的分桶处理 ——3 人房间和 4 人房间被归一化到同一个 1-5 桶,产生相同的哈希。这意味着:如果一个房间连续几次轮询都处于“低活跃、静默 60 分钟、3 条消息“的相似状态,模型只会被调用一次。这对控制成本(无论是 API 费用还是本地算力)至关重要。
5.3 速率限制:主动但不烦人# RateLimiter 兜底保证 bot 永远不会变成刷屏机器:
MaxMessagesPerDay:每个房间每日主动消息上限;
MinIntervalMinutes:两次主动消息的最小间隔。
这三层(规则预过滤 + 决策缓存 + 速率限制)共同确保了:bot 既有主动性,又永远克制 。
六、韧性:在真实世界里不崩溃# 接入外部 LLM API 的系统,失败是常态而非异常。Saber 在 internal/ai 里实现了一套完整的服务韧性栈,这是我认为它最“工业级“的部分。
6.1 三态熔断器# CircuitBreaker 实现了经典的 Closed → Open → HalfOpen 三态转换:
Closed(正常放行)
│ 连续失败达阈值
▼
Open(一律拒绝,快速失败)
│ 经过 reset_timeout
▼
HalfOpen(放一个探测请求)
├─ 成功 → Closed
└─ 失败 → Open 当上游 API 整体宕机时,熔断器直接以 ErrCircuitOpen 拒绝请求,而不是让每个请求都傻等超时——这保护了下游(聊天室的用户体验)和自身(goroutine 堆积)。下面是完整可运行的三态熔断器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package main
import (
"errors"
"fmt"
"sync"
"time"
)
var ErrCircuitOpen = errors.New("circuit breaker is open")
type CircuitState int
const (
StateClosed CircuitState = iota
StateOpen
StateHalfOpen
)
func (s CircuitState) String() string {
switch s {
case StateClosed:
return "Closed"
case StateOpen:
return "Open"
case StateHalfOpen:
return "HalfOpen"
}
return "Unknown"
}
// CircuitBreaker 三态熔断器,保护下游服务不被故障拖垮。
//
// Closed →(失败达阈值)→ Open →(resetTimeout 后)→ HalfOpen → 探测成功 → Closed
// └→ 探测失败 → Open
type CircuitBreaker struct {
mu sync.Mutex
state CircuitState
failureCount int
failureThreshold int
resetTimeout time.Duration
lastFailure time.Time
}
func NewCircuitBreaker(threshold int, reset time.Duration) *CircuitBreaker {
return &CircuitBreaker{
state: StateClosed,
failureThreshold: threshold,
resetTimeout: reset,
}
}
// Allow 判断当前是否放行请求。HalfOpen 只放行一个探测请求。
func (cb *CircuitBreaker) Allow() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.state == StateOpen {
if time.Since(cb.lastFailure) >= cb.resetTimeout {
cb.state = StateHalfOpen
return true // 放行一个探测请求
}
return false
}
if cb.state == StateHalfOpen {
return false // 已有探测在飞,其余请求拒绝
}
return true
}
func (cb *CircuitBreaker) RecordSuccess() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failureCount = 0
if cb.state == StateHalfOpen {
cb.state = StateClosed // 探测成功,恢复
}
}
func (cb *CircuitBreaker) RecordFailure() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failureCount++
cb.lastFailure = time.Now()
if cb.state == StateHalfOpen {
cb.state = StateOpen // 探测失败,立即打开
return
}
if cb.failureCount >= cb.failureThreshold {
cb.state = StateOpen
}
}
// Execute 在熔断器保护下执行一次操作。
func (cb *CircuitBreaker) Execute(op func() error) error {
if !cb.Allow() {
return ErrCircuitOpen
}
if err := op(); err != nil {
cb.RecordFailure()
return err
}
cb.RecordSuccess()
return nil
}
func (cb *CircuitBreaker) State() CircuitState {
cb.mu.Lock()
defer cb.mu.Unlock()
return cb.state
}
func main() {
// 阈值 3 次失败,1 秒后进入半开
cb := NewCircuitBreaker(3, time.Second)
flaky := func() error { return errors.New("500 internal server error") }
// 连续失败 3 次,熔断器打开
for i := 1; i <= 3; i++ {
err := cb.Execute(flaky)
fmt.Printf("第 %d 次调用: err=%v, state=%s\n", i, err, cb.State())
}
// 熔断后请求被快速拒绝(不再真正调用下游)
if err := cb.Execute(flaky); err != nil {
fmt.Printf("熔断后调用: err=%v (快速失败,未触达下游)\n", err)
}
// 等待 resetTimeout,进入半开,探测成功 → 恢复
time.Sleep(time.Second + 50*time.Millisecond)
err := cb.Execute(func() error { return nil })
fmt.Printf("恢复后调用: err=%v, state=%s (已恢复)\n", err, cb.State())
} -- NORMAL -- i 插入 · v 选择 · ⌘↵ 运行
6.2 重试 + 指数退避 + 模型降级# RetryConfigWrapper 在熔断器外层包了一层带退避的重试。关键在于 RetryableError() 精确区分可重试错误与不可重试错误 ——后者重试再多也没用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
package main
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
// RetryableError 判断错误是否值得重试。
// 可重试:5xx / 429 / 超时 / 连接拒绝 / EOF
// 不可重试:401 鉴权失败、403 禁止(重试再多也没用)
func RetryableError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
for _, code := range []string{"429", "500", "502", "503", "504"} {
if strings.Contains(msg, code) {
return true
}
}
for _, net := range []string{"timeout", "connection refused", "dial tcp", "eof", "connection reset"} {
if strings.Contains(msg, net) {
return true
}
}
// 鉴权类错误显式判为不可重试
for _, auth := range []string{"unauthorized", "forbidden", "invalid token", "authentication failed"} {
if strings.Contains(msg, auth) {
return false
}
}
return false
}
// WithRetry 带指数退避的重试。只在"可重试错误"上重试。
func WithRetry(ctx context.Context, maxRetries int, initialDelay, maxDelay time.Duration, backoff float64, op func() error) error {
var lastErr error
delay := initialDelay
for attempt := 0; attempt <= maxRetries; attempt++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := op(); err == nil {
return nil
} else {
lastErr = err
}
if attempt == maxRetries || !RetryableError(lastErr) {
return fmt.Errorf("attempt %d: %w", attempt+1, lastErr)
}
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
delay = time.Duration(float64(delay) * backoff)
if delay > maxDelay {
delay = maxDelay
}
}
return lastErr
}
func main() {
// 场景一:前两次返回 503,第三次成功 → 重试有效
calls := 0
op := func() error {
calls++
if calls < 3 {
return errors.New("http 503 service unavailable")
}
return nil
}
err := WithRetry(context.Background(), 5, 10*time.Millisecond, 100*time.Millisecond, 2.0, op)
fmt.Printf("可重试场景(503×2 后成功): err=%v, 调用次数=%d (预期 3)\n", err, calls)
// 场景二:鉴权错误 → 不重试,立即返回
authCalls := 0
authOp := func() error {
authCalls++
return errors.New("401 unauthorized: invalid api key")
}
err = WithRetry(context.Background(), 5, 10*time.Millisecond, 100*time.Millisecond, 2.0, authOp)
fmt.Printf("鉴权错误(401): err=%v, 调用次数=%d (预期 1,不重试)\n", err, authCalls)
} -- NORMAL -- i 插入 · v 选择 · ⌘↵ 运行
更妙的是 FallbackModelHandler.TryWithFallback():当主模型反复失败,自动切换到配置的备用模型列表。这让“主力模型挂了“不至于让整个 bot 哑火。
6.3 连接韧性# Matrix 侧的 StartSyncWithReconnect() 用指数退避自动重连,配置了最大重试次数、初始延迟、最大延迟。homeserver 短暂抖动对用户透明。
6.4 客户端复用# var sharedTransport = & http. Transport { } 所有 AI 客户端共享同一个 HTTP Transport,复用 TCP 连接和 TLS 握手。对于一个会发起大量短请求的 bot,这是一个小而实在的性能优化。
七、人格:给机器注入性格# internal/persona 是整个项目里最“有人情味“的部分。它允许为每个聊天室单独设定机器人的性格 ——同一个 bot,在 A 群是端庄的英式管家,在 B 群是活泼的猫娘。
底层是一个 SQLite 数据库(纯 Go 的 modernc.org/sqlite,无需 CGO),存人格定义和房间映射。五个内置人格开箱即用:
ID 性格
catgirl猫娘,句尾加“喵“
butler优雅英式管家,恭敬专业
pirate豪爽海盗船长,满口冒险用语
tsundere傲娇,表面冷淡内心温柔
poet文雅诗人,喜用诗词表达
用户还能通过 !persona new 创建自定义人格。关键在于它如何与 AI 服务协作 ——Service 不直接知道人格的存在,只通过一个接口获取系统提示词:
type PromptProvider interface {
GetSystemPrompt ( roomID id. RoomID , basePrompt string ) string
}persona.Service 实现了这个接口,把“全局基础提示词“与“该房间的人格提示词“合并 后返回。AI 服务完全不知道人格的存在,它只看到一个 PromptProvider。又一次,接口隔离让功能正交。下面是这套接口注入的可运行骨架:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package main
import "fmt"
// PromptProvider 接口:AI 服务只依赖它,不感知"人格"的存在。
// 这让"房间人格"与"AI 对话"彻底正交——任何能返回系统提示词的东西都能插进来。
type PromptProvider interface {
GetSystemPrompt(room string, basePrompt string) string
}
type Persona struct {
ID string
Name string
Prompt string
}
// PersonaService 管理"房间→人格"映射,实现 PromptProvider。
type PersonaService struct {
roomPersona map[string]string
personas map[string]Persona
}
func NewPersonaService() *PersonaService {
ps := &PersonaService{
roomPersona: make(map[string]string),
personas: make(map[string]Persona),
}
for _, p := range []Persona{
{"catgirl", "猫娘", `你是一个可爱的猫娘,句尾加"喵~"。`},
{"butler", "管家", "你是一位优雅的英式管家,称呼用户为'主人',用'您'。"},
} {
ps.personas[p.ID] = p
}
return ps
}
func (ps *PersonaService) SetRoomPersona(room, personaID string) {
ps.roomPersona[room] = personaID
}
// GetSystemPrompt 把"全局基础提示词"与"房间人格提示词"合并后返回。
// 调用方拿到的是合并结果,完全不需要知道人格机制的存在。
func (ps *PersonaService) GetSystemPrompt(room, basePrompt string) string {
pid, ok := ps.roomPersona[room]
if !ok {
return basePrompt // 该房间未设人格,原样返回
}
p, ok := ps.personas[pid]
if !ok {
return basePrompt
}
return basePrompt + "\n\n" + p.Prompt
}
func main() {
ps := NewPersonaService()
base := "你是一个有帮助的助手。"
// A 群设为管家,B 群设为猫娘,C 群不设
ps.SetRoomPersona("!A:matrix.org", "butler")
ps.SetRoomPersona("!B:matrix.org", "catgirl")
for _, room := range []string{"!A:matrix.org", "!B:matrix.org", "!C:matrix.org"} {
fmt.Printf("=== %s ===\n%s\n\n", room, ps.GetSystemPrompt(room, base))
}
// AI 服务只看到 PromptProvider 接口——这就是依赖反转
var provider PromptProvider = ps
fmt.Printf("[接口注入] A群提示词: %s\n", provider.GetSystemPrompt("!A:matrix.org", base))
} -- NORMAL -- i 插入 · v 选择 · ⌘↵ 运行
八、多平台:Matrix 与 QQ 的二元# Saber 同时面向两个差异巨大的平台。Matrix 是开放的联邦协议,支持消息编辑(于是能做流式渲染);QQ 官方 API 是封闭的频道机器人,不支持消息编辑 。
面对这种能力差异,Saber 没有把所有逻辑塞进一个臃肿的服务,而是分裂出两个 AI 服务实现:
Service (完整版):流式、工具调用、媒体理解、主动聊天、上下文管理——为 Matrix 而生;
SimpleService (精简版):只做基础的一问一答——为 QQ 而生。
两者共享同一个 Core(客户端创建、模型注册表、配置),只在“能力表面“上分化。QQ 适配器通过一个 aiServiceAdapter 把 *ai.SimpleService 适配到自己的接口:
type aiServiceAdapter struct { svc *ai.SimpleService } 这是适配器模式的纯粹应用:用一个薄薄的转换层,让能力不匹配的服务也能接入 ,而不必污染任何一方的实现。
九、安全与部署# 9.1 端到端加密# Matrix 的 E2EE 通过 goolm 实现——一个纯 Go 的 Olm 实现 ,无需 CGO。这意味着 Saber 可以被编译成完全静态链接的二进制,塞进 Distroless 镜像。构建时只需 -tags goolm。
加密会话用一个持久化的 pickle key 加密存储,避免重启后出现 “olm account is not marked as shared” 错误。会话文件本身含访问令牌,因此配置项 strict_session_perm_check 可以强制要求 0600 权限,否则拒绝启动。
9.2 输出净化# 任何 HTML 输出都过 bluemonday(matrix.SanitizeHTML())消毒,防止 XSS。模型生成的 Markdown 被渲染成 HTML 后,也要走这道闸。
9.3 Distroless + 多架构# Dockerfile 是教科书式的多阶段构建:
# 阶段 1: golang:1.26-alpine, CGO_ENABLED=0, -tags goolm, 静态链接, -trimpath
# 阶段 2: gcr.io/distroless/static-debian12 最终镜像约 20MB,无 shell、无包管理器、默认非 root 用户——最小攻击面 。docker-bake.hcl 进一步支持 linux/amd64 + linux/arm64 多架构构建。Makefile 的 build-all 甚至覆盖到 LoongArch64。
十、工程美学# 最后,谈谈代码本身透露出的工程品味。
10.1 测试与生产代码的比例# 行数
生产代码(非测试 .go) ~18,900
测试代码 ~35,000
测试代码约为生产代码的 1.85 倍 。每个包都有独立的 *_test.go,大量采用表驱动测试,用 t.TempDir() 做隔离。这不是“为了覆盖率而写“的测试——bot_run_test.go(21KB)、presence_test.go(32KB)、mention_test.go(22KB)这些体量说明,关键路径被认真地防御着。
10.2 设计模式的克制运用# Saber 用了策略模式(ClientStrategy 区分 OpenAI/Azure 提供商)、工厂模式(MCPServerFactory)、命令模式(命令路由与子命令分发)、适配器模式(QQ)。但每种模式都出现在真正需要扩展点 的地方,而不是为了“显得有架构“而堆砌。ClientFactory 的默认实现自动注册内置策略,全局单例避免重复创建——简单、够用。
10.3 注释即文档# 项目有一份明确的注释规范 ,所有导出符号都带中文注释,说明“它是什么、参数、返回值、为什么这么做“。这种一致性让代码自带可读性,新人(或 AI agent)不需要反复追问意图。
10.4 配置的分层与向后兼容# config.go 有 1207 行,折射出功能演进的痕迹:新旧字段并存(Providers 推荐用法 vs. 旧的单提供商字段),每一层能力(重试、熔断、流式编辑、主动聊天)都有独立的配置块。配置加载后还会做结构化校验,把错误挡在启动早期。
Saber 不是那种“周末玩具“。它是一个在真实聊天环境里长期运行、被持续打磨的系统。读它的代码,你能看到大量“踩过坑才会有的设计“:
流式编辑要节流,否则被 homeserver 限流;
决策要缓存,否则模型账单爆炸;
工具调用要有界,否则模型死循环;
stdio 要白名单,否则命令注入;
沙箱要禁危险 API,否则 RCE;
pickle key 要持久化,否则加密状态丢失;
关闭要并行 + 超时,否则卡死在某个不响应的服务上。
每一个设计点背后,都是一个真实发生过的问题。
而把这一切组织得井井有条的,是 Go 的接口、是单向依赖的包结构、是 1.85 倍于生产代码的测试、是对“深模块“原则的坚持。Saber 证明了一件事:一个功能丰富的机器人,也可以是一个优雅的工程。
如果你正在寻找一个“生产级 AI bot“的参考实现——无论是想部署它、学习它,还是从它身上偷设计——Saber 都值得你花一个下午通读一遍。
项目地址:github.com/VOD-Studio/saber · Go 1.26.1 · MIT License
评论
按时间排序