AI文章智能速读 (TL;DR)
收起

之前我写过一篇关于 Firebase Genkit for Go 的入门文章,聊了聊 Google 专为 Go 开发者推出的 AI 框架基础。但纸上得来终觉浅,官方写写 Hello World 和真正做一个跑在后台、自己每天高频使用的私人管家系统,完全是两码事。

这篇笔记是我基于 Go 语言、Google Gemini Live 原生多模态协议、Genkit、PostgreSQL (pgvector) 与 Redis,从零打造我的专属数字管家 Iris 的完整实战复盘。文中不讲虚头巴脑的公关词,代码均已通用泛化脱敏,力求用最通俗大白话把核心设计、通信链路与踩坑血泪史讲透。


零、 为什么我不想要一个“普通的 Chatbot”?

目前接触到的大多数开源 AI 助手,主要还是围绕着经典的“Web 对话框”范式来构建的:
通过 HTTP 或 SSE 接收文本输入,流式打字机返回内容;如果带日程提醒,也大多依赖轮询扫表的定时任务来支撑。

这种经典的交互模式在写长文、查资料时非常实用,但如果我想把它作为一个随时常驻在后台、融入日常桌面工作流的“私人管家”,在交互细节上就会遇到几个明显的体验瓶颈:

  1. “对讲机式”的半双工体验:
    人类正常聊天是**全双工(Full-Duplex)**的——你可以一边听我说话,一边点头回应;当我废话连篇时,你可以随时插话打断我。而传统 Chatbot 是典型的“对讲机”:你按下按钮说完一句,等待几秒,看它吐一段长文,它说话时往往无法随口打断。
  2. 缺乏视觉现场感:
    写代码遇到报错,或者想让 AI 帮我看看桌上的实物、图纸时,传统做法通常是:截图或拍照 -> 保存 -> 传文件 -> 打字描述。这个过程稍显繁琐,容易打断当下的思考。
  3. 记忆像鱼一样只有 7 秒:
    每次开新对话,我都得重新交代背景习惯。上下文一旦重置,AI 很难自动延续之前形成的默契。
  4. 被动响应难以承担“管家”角色:
    真正的管家需要具备现实时间感知,时间到了能够主动走过来提醒;而普通对话助手通常只能“被动等调用”,不主动发消息它就不会有动作。

为了在这些交互细节上做些更贴近自然沟通的探索,我启动了 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)又需要 import reminder 包来新建提醒。

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)

折腾到现在,这套系统在我自己的机器上跑得还算稳定。全双工语音对讲、屏幕共享看代码、记忆检索以及到期主动语音提醒这些核心需求基本都顺畅了,日常挂在后台确实能帮我省掉不少切窗口和打字的麻烦。

当然,系统还有不少粗糙的地方留待后续慢慢打磨:

  1. 长连接重连容错:长时间挂在后台偶发 WebSocket 波动时,目前的自动重连还不够丝滑,有时需要手动刷新页面;
  2. 免手操作(Wake Word):目前对讲还是需要点一下网页上的按钮,后续打算在前端加个本地轻量的唤醒词检测,做到随喊随到;
  3. 本地系统控制:目前它只能“看”和“说”,接下来考虑通过受限的本地脚本给它增加一些直接操控电脑的能力(比如查询本机系统负载、打开指定工程、控制音乐播放等)。

写代码就是这样,先把最核心的主干链路跑通,剩下的边缘体验再一步一步慢慢补全。

评论交流

发表你的评论...
0 / 500
评论交流 (0)
暂无评论,快来抢占第一条沙发吧~
2
0
AIAI