之前我写过一篇关于 Firebase Genkit for Go 的入门文章,聊了聊 Google 专为 Go 开发者推出的 AI 框架基础。但纸上得来终觉浅,官方写写 Hello World 和真正做一个跑在后台、自己每天高频使用的私人管家系统,完全是两码事。
这篇笔记是我基于 Go 语言、Google Gemini Live 原生多模态协议、Genkit、PostgreSQL (pgvector) 与 Redis,从零打造我的专属数字管家 Iris 的完整实战复盘。文中不讲虚头巴脑的公关词,代码均已通用泛化脱敏,力求用最通俗大白话把核心设计、通信链路与踩坑血泪史讲透。
零、 为什么我不想要一个“普通的 Chatbot”?
目前接触到的大多数开源 AI 助手,主要还是围绕着经典的“Web 对话框”范式来构建的:
通过 HTTP 或 SSE 接收文本输入,流式打字机返回内容;如果带日程提醒,也大多依赖轮询扫表的定时任务来支撑。
这种经典的交互模式在写长文、查资料时非常实用,但如果我想把它作为一个随时常驻在后台、融入日常桌面工作流的“私人管家”,在交互细节上就会遇到几个明显的体验瓶颈:
- “对讲机式”的半双工体验:
人类正常聊天是**全双工(Full-Duplex)**的——你可以一边听我说话,一边点头回应;当我废话连篇时,你可以随时插话打断我。而传统 Chatbot 是典型的“对讲机”:你按下按钮说完一句,等待几秒,看它吐一段长文,它说话时往往无法随口打断。 - 缺乏视觉现场感:
写代码遇到报错,或者想让 AI 帮我看看桌上的实物、图纸时,传统做法通常是:截图或拍照 -> 保存 -> 传文件 -> 打字描述。这个过程稍显繁琐,容易打断当下的思考。 - 记忆像鱼一样只有 7 秒:
每次开新对话,我都得重新交代背景习惯。上下文一旦重置,AI 很难自动延续之前形成的默契。 - 被动响应难以承担“管家”角色:
真正的管家需要具备现实时间感知,时间到了能够主动走过来提醒;而普通对话助手通常只能“被动等调用”,不主动发消息它就不会有动作。
为了在这些交互细节上做些更贴近自然沟通的探索,我启动了 Iris。
一、 声音的灵魂:全双工对讲与“开口即打断”(Local Barge-In)
1. 为什么没有采用“录音转文字 -> 调大模型 -> 文字转语音”的三段式方案?
很多人做语音助手,直觉上会搭这样一条流水线:
\text{麦克风录音} \xrightarrow{1} \text{Whisper 语音识别} \xrightarrow{2} \text{大模型生成文本} \xrightarrow{3} \text{TTS 语音合成} \xrightarrow{4} \text{扬声器播报}
这条链路每一站都在累加网络和计算延迟:
- 录音分块需要时间(至少 500ms);
- ASR 识别需要时间(300ms ~ 800ms);
- LLM 生成第一批文字需要时间(500ms ~ 1000ms);
- TTS 合成整句音频切片又需要时间(500ms)。
整套走下来,端到端延迟通常在 2 秒到 4 秒。对讲感极其明显,根本无法形成真实对话的“心流”。
Iris 的解法:直通 Gemini Live 纯原生双向流。
我们让浏览器麦克风直接采集裸音频帧,通过 WebSocket 像水流一样持续灌给 Go 后端,后端原封不动直接打通到 Gemini Live 的双向流通道。大模型直接感知声音振幅与语调,并在模型内部直接实时吐出人声音频切片(采用 Aoede 音色,带有真人自然的换气停顿与重音)。端到端延迟直接缩减到了几百毫秒级。
【客户端】 【Go 后端】 【Gemini Live API】
麦克风采集 (16kHz PCM) ──(WebSocket)──> 接入层 Hub ──(Bidi Streaming)──> 模型原生理解音频
扬声器排队 (24kHz PCM) <──(WebSocket)── 分发层 <──(Audio Chunks)──── 模型原生吐出语音
2. 核心代码:0ms 本地瞬时打断(Local Barge-In)
在语音交互中,**打断(Barge-In)**是决定质感的命门。
如果把打断逻辑放在服务端(等服务端 VAD 检测到你说话,再发指令通知浏览器停止播放),网络来回一次(RTT)就要百来毫秒。实际体验就是:你已经大喊“停”,耳机里 AI 还要硬生生多说半句话,体验非常糟糕。
我的做法是:将第一道打断防线直接部署在客户端麦克风采集层。
客户端采集每一帧音频时,本地计算人声能量均方根(RMS)。只要 AI 此时正在扬声器里发声,而用户的麦克风音量突然超过设定的能量阈值,前端在 0 毫秒内就地切断本地音频播放,同时异步向上游发送打断信标:
javascript
// 客户端 Web Audio API 实时音频处理节点
micProcessor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
// 1. 本地计算音频能量均方根 (RMS - Root Mean Square)
let sumSquares = 0;
for (let i = 0; i < inputData.length; i++) {
sumSquares += inputData[i] * inputData[i];
}
const rms = Math.sqrt(sumSquares / inputData.length);
// 2. 核心打断逻辑:如果 AI 正在耳机里说话,且麦克风检测到用户突然开嗓(突破阈值 0.025)
if (rms > 0.025 && activeAudioSources.length > 0) {
// 瞬间清空浏览器当前所有排队的 AudioBuffer 播放源(0ms 本地静音)
stopAllLocalAudio();
// 向上游 Go 后端异步发送打断信号,告诉大模型立即截断本轮输出
ws.send(JSON.stringify({ type: "interrupt" }));
}
// 3. 将 32 位浮点音频转化为标准的 16kHz 16-bit 线性 PCM
const pcm16 = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
const s = Math.max(-1, Math.min(1, inputData[i]));
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
// 4. 将音频切片 Base64 编码后通过 WebSocket 持续推流
ws.send(JSON.stringify({
type: "audio_chunk",
data: { pcm: arrayBufferToBase64(pcm16.buffer) }
}));
};
通俗解释:这就好比你戴着耳机听别人讲话,一旦你自己的声带产生震动,你的大脑立刻主动把耳机的声音给掐断,而不是等对方听到你说话、反应过来闭嘴后再停止。
二、 视觉的秘密:为什么不是传视频,而是抽帧?
在刚想给 Iris 加“视觉”时,很多人的第一想法是推一段 H.264 视频流或者拉 WebRTC。
但踩过坑就知道,这在目前的 AI 交互中是绝对的反模式:
- 视频编码极大消耗移动端和电脑的 CPU/GPU 算力;
- 大模型底层并不是一帧帧看动态视频,它是将图像拆解为二维 Patch(视觉切片),当作特殊的图像 Token 来理解。
工业级优雅解法:连续高频图像帧流(1 FPS JPEG)。
我们不需要像打游戏那样推 60 帧,对于大模型看懂屏幕上的代码、桌上的物品,每秒截取 1 张图(1 FPS) 已经足够模型捕捉所有动态变化,而且网络开销低到可以忽略不计:
javascript
// 前端每秒(1000ms)从屏幕共享或摄像头截取一帧
setInterval(() => {
if (!videoElement.videoWidth) return;
// 动态绘制到隐藏的 Canvas 上,尺寸等比缩放至 720p(1280x720)
canvas.width = Math.min(1280, videoElement.videoWidth);
canvas.height = Math.round(canvas.width * (videoElement.videoHeight / videoElement.videoWidth));
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
// 压缩为质量 0.65 的轻量 JPEG 图片
const dataUrl = canvas.toDataURL("image/jpeg", 0.65);
const b64Jpeg = dataUrl.split(",")[1];
// 发往 Go 后端
ws.send(JSON.stringify({
type: "screen_frame", // 或 video_frame (摄像头)
data: { data: b64Jpeg, ts: Date.now() }
}));
}, 1000);
而在 Go 服务端,我们只需要把这个 JPEG 字节流直接喂给 Live API 的多模态视频帧通道:
go
// internal/genkit/live.go (泛化示意)
func (ls *LiveSession) SendImage(jpegBytes []byte) error {
return ls.session.SendRealtimeInput(genai.LiveSendRealtimeInputParameters{
Video: &genai.Blob{
MIMEType: "image/jpeg",
Data: jpegBytes,
},
})
}
通俗效果:你共享着你的 IDE,一边用嘴说话:“Iris,看一下第 50 行的报错是啥意思”,大模型在同一个长连接里,左耳听着你的 PCM 声音,右眼看着你每秒送过来的 720p 截图,瞬间开口给你指出代码第 50 行少了括号。
三、 智能的中枢:Genkit Go 工具系统与领域拆解
在初期开发时,我把所有工具函数(查时间、查数据库、搜百度、记笔记)全塞进一个巨大的 tools.go 文件。结果项目写到后面,一个文件近两千行,各个模块互相交织,维护起来痛苦不堪。
后来我进行了按领域拆分(Domain Splitting),整体架构变得异常清爽:
internal/genkit/
├── genai.go # Genkit 运行时统一初始化 (G 实例与全局工具挂载)
├── live.go # Gemini Live 双向 PCM 实时通信会话管理
├── memory.go # pgvector 1536 维向量检索与持久化底层封装
├── tool_system.go # 领域 1:物理系统时钟 (提供时间物理锚点)
├── tool_user.go # 领域 2:用户状态与 Casbin 权限
├── tool_memory.go # 领域 3:长期记忆的读写工具
├── tool_reminder.go # 领域 4:日程提醒的增删改查
├── tool_search.go # 领域 5:全网 DuckDuckGo 双通道搜索
└── tools.go # 统一注册中心 (registerTools)
编写工具的范式代码(以系统基准时钟为例)
大模型最容易犯的错误就是“不知今夕是何年”。如果用户问“明天下午三点叫我开会”,模型没有现实时钟参考,只能胡编乱造。因此,提供一个权威系统时钟是第一步:
go
// internal/genkit/tool_system.go
package genkit
import (
"fmt"
"time"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/genkit"
)
// timeTool 获取后端服务器当前的精确时间与时区
func timeTool() ai.ToolRef {
return genkit.DefineTool(G, "getServerTime", "获取后端服务器当前的精确时间与时区,计算相对时间时必须先调用此工具",
func(ctx *ai.ToolContext, _ struct{}) (string, error) {
nowStr := time.Now().Format("2006-01-02 15:04:05 MST")
return fmt.Sprintf("当前服务器绝对时间为:%s", nowStr), nil
},
)
}
在 tools.go 中,将所有子工具一次性追加注册:
go
// internal/genkit/tools.go
package genkit
func registerTools() {
Tools = append(Tools,
timeTool(), // 系统时钟
saveMemoryTool(), // 记忆存入
searchMemoryTool(), // 记忆检索
setReminderTool(), // 倒计时提醒设定
listRemindersTool(), // 待办列表查询
searchWebTool(), // 全网实时检索
)
}
通过反射函数 buildLiveTools(),这套用 Genkit 标准定义的工具集,能自动转换为 Gemini Live 所要求的 FunctionDeclaration。无论是语音对讲还是后续的后台 Flow,工具写一次,全局任意复用。
四、 记忆的底座:pgvector 语义检索与原生避坑
人类管家不会让你把身世背景重复交代第二遍。要让 Iris 越用越懂我,必须拥有高精度的长期记忆体系。
1. 为什么不用传统关系型数据库模糊查询(LIKE)?
如果你问 AI:“李总能接受的底价是多少?”
如果数据库里存的句子是:“李总谈判时表示85折是他的极限让步”。
传统的 SQL WHERE content LIKE '%底价%' 命中率为 0,因为文字里根本没有“底价”两个字。
必须使用向量嵌入(Embedding)进行语义检索。
2. 向量入库与 pgvector 踩坑实践
Iris 采用了 PostgreSQL 官方扩展 pgvector,并搭配 Google 最新的 gemini-embedding-2(1536 维特征空间)。
在 Go 中执行向量余弦距离(Cosine Distance)查询时,很多网上代码直接写 embedding <=> $1,在 PostgreSQL 底层会报错 operator does not exist: vector <=> text。必须对传入的参数做显式强制类型转换:
go
// internal/genkit/memory.go (泛化与核心逻辑)
package genkit
import (
"context"
"fmt"
"github.com/pgvector/pgvector-go"
)
// SearchMemory 基于语义向量从 PostgreSQL 中检索历史相关事实与偏好
func SearchMemory(ctx context.Context, userID int, query string, limit int) ([]*MemorySearchResult, error) {
// 1. 调用 Embedding 模型,将用户的问题转化为 1536 维浮点数向量
queryVector, err := GetEmbedding(ctx, query)
if err != nil {
return nil, fmt.Errorf("生成检索向量失败: %w", err)
}
// 2. 核心 SQL:使用 1 - (embedding <=> $1::vector) 计算标准的余弦相似度 (0.0 ~ 1.0)
// <=> 是 pgvector 的余弦距离操作符,距离越小越相似;因此用 1 减去距离得到相似度分数
sqlQuery := `
SELECT id, title, content, category, importance,
1 - (embedding <=> $1::vector) AS similarity,
created_at
FROM memories
WHERE user_id = $2
ORDER BY similarity DESC
LIMIT $3;
`
rows, err := dbPool.Query(ctx, sqlQuery, pgvector.NewVector(queryVector), userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []*MemorySearchResult
for rows.Next() {
var m MemorySearchResult
if err := rows.Scan(&m.ID, &m.Title, &m.Content, &m.Category, &m.Importance, &m.Similarity, &m.CreatedAt); err != nil {
continue
}
results = append(results, &m)
}
return results, nil
}
通俗解释:向量化就像把人类所有的语言投射在一个 1536 维的立体宇宙里。“极限让步”和“底价”虽然字面不同,但在宇宙里的坐标几乎挨在一起。AI 通过计算两个坐标的夹角距离,瞬间就能从成千上万条历史记录里把你曾经说过的细节翻出来。
五、 主动的管家:告别死循环轮询,Redis TTL 事件驱动
这是在 Iris 系统中,我认为相对比较轻量、有效的一个设计解法。
1. 传统闹钟轮询的硬伤
很多 AI 助手是怎么做“10分钟后提醒我倒水”的?
后台写个死循环:
go
// 传统低效做法:每分钟扫描一次数据库
for range time.Tick(1 * time.Minute) {
db.Query("SELECT * FROM reminders WHERE remind_at <= NOW() AND is_sent = false")
}
这种做法不仅每分钟都在给数据库增加无谓的 I/O 负担,而且到期时间存在最多 60 秒的迟钝误差,完全没有科技感。
2. 我的设计:Redis TTL Key-Expired 事件驱动
Iris 彻底抛弃了轮询,采用了事件驱动(Event-Driven)的精准倒计时架构:
用户说:“10分钟后提醒我喝水”
│
├──> 1. DB 写入持久化记录 (备查,is_sent = false)
├──> 2. Redis 写入元数据快照 Hash (reminder:data:ID)
└──> 3. Redis 写入一个带精确 TTL 的空键:
SETEX reminder:timer:42 600 "1"
【随后整个系统进入 0 CPU、0 数据库 I/O 的完全休眠状态】
Redis 定时器精确到第 600 秒整,Key 瞬间蒸发!
│
└──> Redis 内部 PubSub 自动广播事件:__keyevent@*__:expired
│
└──> Go 后端的单一事件监听协程瞬间捕获该事件!
│
├── 毫秒级从 Redis Hash 取出元数据
├── 更新 DB 状态 is_sent = true
├── WebSocket 向前端下发弹窗通知
└── 核心一击:唤醒在线的 Gemini Live 语音通道!
3. 核心实现代码
① 倒计时注册(AddReminder)
go
// internal/reminder/scheduler.go (核心片段)
func AddReminder(ctx context.Context, userID int, content string, remindAt time.Time) error {
ttl := time.Until(remindAt)
if ttl <= 0 {
return fmt.Errorf("提醒时间必须在未来")
}
// 1. 数据库落库
rem, _ := db.Client.Reminder.Create().
SetUserID(userID).
SetContent(content).
SetRemindAt(remindAt).
Save(ctx)
// 2. Redis Hash 存元数据快照(方便到期时毫秒级直读,无需查数据库)
payload, _ := json.Marshal(map[string]any{
"id": rem.ID, "user_id": userID, "content": content,
})
_ = db.Redis.HSet(ctx, "reminder:data", rem.ID, payload).Err()
// 3. 设定精确的 Redis TTL 倒计时键(到期自动消失)
timerKey := fmt.Sprintf("reminder:timer:%d", rem.ID)
_ = db.Redis.Set(ctx, timerKey, "1", ttl).Err()
return nil
}
② 监听 Redis 过期事件(StartReminderListener)
go
// 开启 Redis 键空间过期通知监听
func StartReminderListener() {
ctx := context.Background()
// 监听所有数据库的 key 过期事件
pubsub := db.Redis.PSubscribe(ctx, "__keyevent@*__:expired")
for msg := range pubsub.Channel() {
// 匹配前缀格式:reminder:timer:42
if strings.HasPrefix(msg.Payload, "reminder:timer:") {
idStr := strings.TrimPrefix(msg.Payload, "reminder:timer:")
reminderID, _ := strconv.Atoi(idStr)
// 异步触发处理,不阻塞监听通道
go handleReminderTrigger(reminderID)
}
}
}
③ 终极质感:驱动 Gemini Live 主动“开口”说话
当提醒到期时,如果仅仅是手机弹个窗、响声“叮”,那它依然只是个闹钟。
Iris 的做法是:通过注入带有 TurnComplete: true 的系统事件,强制唤醒当前在线的 Gemini Live 会话主动开口说话!
go
// internal/genkit/live.go
func (ls *LiveSession) NotifyReminder(content string) error {
// 构建口头汇报的系统 Prompt
prompt := fmt.Sprintf("【系统事件:定时提醒时间已到】用户设定的提醒事项“%s”现在到期了。请你作为智能管家 Iris,现在立刻用沉稳自然的中文普通话主动向用户播报该提醒,直接说出提醒内容,语气自然亲切干练,不要解释这是系统通知。", content)
// 发送 ClientContent 并强制 TurnComplete = true
return ls.session.SendClientContent(genai.LiveClientContentInput{
Turns: []*genai.Content{
{
Role: "user",
Parts: []*genai.Part{{Text: prompt}},
},
},
TurnComplete: new(true), // 关键:告知模型此轮输入已完整,模型会立刻开始推理并直接开口发声!
})
}
实际效果:你在戴着耳机写代码,10 分钟时间一到,不需要你碰任何键盘或屏幕,耳机里 Iris 直接从容开口:“先生,您设定的倒水时间到了。”
六、 工程架构的避坑与取舍哲学
在把各个模块揉捏在一起的过程中,有两个非常深刻的架构感悟:
1. 规避 Go 循环依赖(Import Cycle)的“回调解耦法”
在实现主动提醒时,我遇到了一个极其恶心的包循环引用问题:
reminder包到期后,需要调用ws包下发推流;ws包管理着活跃的客户端,内部需要调用genkit包驱动语音播报;genkit包里的工具(tool_reminder.go)又需要 importreminder包来新建提醒。
Go 编译器会当场翻脸:
imports reminder -> imports ws -> imports genkit -> imports reminder: import cycle not allowed。
很多初学者遇到这个问题,喜欢妥协着建一个乱七八糟的全局公共包,或者把代码全堆在一起。
最好的解法是:下层包定义函数签名,上层包在启动时单向注入回调(Notifier Pattern):
go
// 1. 在底层 internal/reminder/scheduler.go 中只定义回调类型与全局变量
type NotifierFunc func(userID uint, reminderID int, content, remindAt string)
var Notifier NotifierFunc
func SetNotifier(fn NotifierFunc) {
Notifier = fn
}
// 到期时只管触发回调,完全不需要 import 任何关于 ws 的代码
if Notifier != nil {
Notifier(uint(payload.UserID), payload.ID, payload.Content, payload.RemindAt)
}
而在上层 internal/ws/hub.go 启动时,由 ws 包负责把自身的推送动作挂载进去:
go
// internal/ws/hub.go
func (m *Manager) Start() {
// 将上层的实际业务逻辑作为回调函数塞给底层
reminder.SetNotifier(func(userID uint, reminderID int, content, remindAt string) {
// 执行 WebSocket 推送以及 Gemini Live 语音主动呼叫...
})
// ...
}
底层不知上层是谁,上层负责装配逻辑。依赖环瞬间消解,每个包依然保持高度自治。
2. 拒绝过度设计(坚守 YAGNI 原则)
在完善到期触发防重逻辑时,我一度想过:要不要上 Redis 分布式排他锁?要不要在 PostgreSQL 里做严密的原子状态抢占(UPDATE ... WHERE is_sent = false)?
但深夜坐下来思考:我做的是我个人的私人数字副手,单实例运行在我的本地服务器上。
单机 Redis 的 Key-Expired 事件到达天然就是单次事件,只要在数据库查询兜底里加上 Where(reminder.IsSent(false)) 保护,系统就已经坚不可摧了。
如果为了追求看似“高大上”的分布式强一致性,在原本微秒级的内存链路上多加几道数据库行锁与网络通信,既拖慢了交互响应,又增加了排查复杂性。
“不为臆想中的千万并发买单(YAGNI - You Aren't Gonna Need It)”,在做个人系统设计时,永远把代码的可读性、响应极速和架构简单性放在第一位。
七、 当前现状与后续待办(TODO)
折腾到现在,这套系统在我自己的机器上跑得还算稳定。全双工语音对讲、屏幕共享看代码、记忆检索以及到期主动语音提醒这些核心需求基本都顺畅了,日常挂在后台确实能帮我省掉不少切窗口和打字的麻烦。
当然,系统还有不少粗糙的地方留待后续慢慢打磨:
- 长连接重连容错:长时间挂在后台偶发 WebSocket 波动时,目前的自动重连还不够丝滑,有时需要手动刷新页面;
- 免手操作(Wake Word):目前对讲还是需要点一下网页上的按钮,后续打算在前端加个本地轻量的唤醒词检测,做到随喊随到;
- 本地系统控制:目前它只能“看”和“说”,接下来考虑通过受限的本地脚本给它增加一些直接操控电脑的能力(比如查询本机系统负载、打开指定工程、控制音乐播放等)。
写代码就是这样,先把最核心的主干链路跑通,剩下的边缘体验再一步一步慢慢补全。


评论交流