在 AI 输入法赛道上,绝大多数方案沿用的是"ASR + LLM"两步流水线——先用 Whisper 等模型把语音转成文字,再用大模型做润色、纠错、格式化。这套方案成熟可靠,但存在一个根本性的信息瓶颈:ASR 环节把丰富的音频信号压缩成了纯文本,语气、停顿、重音等副语言信息在第一步就丢失了。
百隅科技(ByuTech)开源的 Keyfree 选择了另一条路——直接让多模态 LLM 处理原始音频,一次 API 调用完成从语音到结构化文本的全部工作。本文将深入解析这套架构的设计思路、工程实现和取舍决策。
一、整体架构:一次调用,端到端
传统方案的数据流是:
Audio → ASR Model → Raw Text → LLM → Refined Text
Keyfree 的数据流是:
Audio → Multimodal LLM (audio + context prompt) → Final Text
差异看起来只是少了一步,但工程影响是深远的:
- 延迟减半:只有一次网络往返,不存在两个模型之间的串行等待。
- 信息保真:LLM 能"听到"原始音频,可以根据语气判断标点、根据停顿分段、根据重音识别强调。
- 上下文统一:场景信息(当前应用、窗口标题等)和音频在同一个 prompt 中送入,模型能在一次推理中同时理解"说了什么"和"在哪说的"。
核心状态机
Keyfree 的运行时围绕一个五状态的有限状态机展开:
enum AppState {
case idle // 等待用户触发
case recording // 录音中
case transcribing // 音频已发送,等待 LLM 响应
case inserting // 正在向目标应用插入文本
case showingResult // 展示结果 / 错误信息
}
状态转移清晰且单向(正常路径),任何异常都回到 idle。这种设计避免了复杂的并发状态管理,在单用户桌面应用场景下既简单又可靠。
二、音频处理:手工构建 WAV
Keyfree 使用 AVFoundation 录制 16kHz mono PCM 音频。值得注意的是,项目没有引入任何第三方音频编解码库,而是手动构建 WAV 文件头:
// 手动构建 WAV 头部(44 字节)
func buildWAVHeader(dataSize: Int, sampleRate: Int = 16000, channels: Int = 1, bitsPerSample: Int = 16) -> Data {
var header = Data(capacity: 44)
// RIFF chunk
header.append(contentsOf: "RIFF".utf8)
header.append(UInt32(36 + dataSize).littleEndianBytes)
header.append(contentsOf: "WAVE".utf8)
// fmt sub-chunk
header.append(contentsOf: "fmt ".utf8)
header.append(UInt32(16).littleEndianBytes) // Sub-chunk size
header.append(UInt16(1).littleEndianBytes) // PCM format
header.append(UInt16(channels).littleEndianBytes)
header.append(UInt32(sampleRate).littleEndianBytes)
header.append(UInt32(sampleRate * channels * bitsPerSample / 8).littleEndianBytes) // Byte rate
header.append(UInt16(channels * bitsPerSample / 8).littleEndianBytes) // Block align
header.append(UInt16(bitsPerSample).littleEndianBytes)
// data sub-chunk
header.append(contentsOf: "data".utf8)
header.append(UInt32(dataSize).littleEndianBytes)
return header
}
选择 16kHz 采样率是一个平衡点:对语音识别来说信息量足够(人声基频通常在 85-255Hz),同时数据量只有 44.1kHz 的约三分之一。PCM 数据最终经过 Base64 编码后作为多模态 LLM API 的 audio 参数传输。
为什么不用压缩格式? Opus 或 AAC 确实能大幅减小传输体积,但会引入编解码依赖。Keyfree 的设计哲学是零外部依赖,且语音输入通常只有几秒到十几秒,Base64 编码后的体积在可接受范围内。
三、上下文感知:三层系统
这是 Keyfree 最具技术含量的设计之一。语音输入的一个核心痛点是"同音不同字"和"格式不确定"——同样一句话,在写代码时、写邮件时、聊天时,期望的输出完全不同。Keyfree 通过三层上下文系统来解决这个问题:
Layer 1:实时上下文
struct RealtimeContext {
let appName: String // 如 "Xcode", "微信", "飞书"
let windowTitle: String // 如 "MyProject - ViewController.swift"
let focusElement: String // 如 "Code Editor", "Search Field"
}
通过 macOS Accessibility API 实时获取。当用户在 Xcode 中录音时,模型知道这是一个编程场景,会倾向于输出代码相关术语的正确拼写;在微信中录音时,则会使用更口语化的表达。
Layer 2:短期记忆
每 10 次输入或累积 500 字时触发一次摘要,时间窗口为 1 小时。摘要提取:
- 当前讨论主题
- 用户意图方向
- 所在领域(技术/商务/日常)
- 高频词汇
- 出现的实体(人名、项目名等)
这层解决的是"连续对话"场景:当你在讨论某个技术方案时,后续的语音输入会自动继承之前的专业术语偏好。
Layer 3:长期用户画像
每日生成,基于 7 天滚动窗口,提取:
- 用户身份特征(职业、领域)
- 专业领域分布
- 语言风格习惯
- 常用实体列表
持久化到 Core Data,实现跨会话的个性化。
Prompt 栈式优先级
三层上下文通过栈式优先级合并到最终 prompt 中:
基础指令 < 术语表 < 用户画像 < 短期记忆 < 实时上下文 < 自定义指令
越靠近栈顶,优先级越高。这意味着用户的自定义指令永远能覆盖系统默认行为,而实时上下文能覆盖长期画像——你平时写代码为主,但此刻在写邮件,模型会以邮件场景为准。
四、文本插入:三级降级策略
把 LLM 生成的文本插入到目标应用的光标位置,听起来简单,实际上是 macOS 开发中的一个经典难题。不同应用对输入事件的处理差异极大。Keyfree 设计了三级降级策略:
| 级别 | 方式 | 延迟 | 兼容性 | 适用场景 |
|---|---|---|---|---|
| L1 | Accessibility API (AXValue) | ~0ms | ~70% | 原生 NSTextField/NSTextView |
| L2 | CGEvent 键盘模拟 | 中等 | ~95% | 大部分应用 |
| L3 | 剪贴板注入 | 较高 | 100% | 兜底方案 |
func insertText(_ text: String) async {
// L1: 尝试直接设置 AXValue
if let focusedElement = getFocusedElement(),
AXUIElementSetAttributeValue(focusedElement, kAXValueAttribute as CFString, text as CFTypeRef) == .success {
return
}
// L2: CGEvent 键盘模拟,逐字符合成按键
if simulateKeyboardInput(text) {
return
}
// L3: 剪贴板兜底
let savedClipboard = NSPasteboard.general.string(forType: .string)
NSPasteboard.general.setString(text, forType: .string)
simulateCommandV()
// 恢复剪贴板
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if let saved = savedClipboard {
NSPasteboard.general.setString(saved, forType: .string)
}
}
}
L3 的剪贴板方案会先保存用户剪贴板内容,注入后再恢复——这是一个容易被忽视但对用户体验至关重要的细节。
五、流式响应:SSE + 缓冲刷新
Keyfree 使用 SSE(Server-Sent Events)接收 LLM 的流式响应,配合 30ms 的缓冲刷新策略:
class SSEStreamHandler {
private var buffer = ""
private var flushTimer: Timer?
private let flushInterval: TimeInterval = 0.03 // 30ms
func onReceiveChunk(_ chunk: String) {
buffer.append(chunk)
scheduleFlush()
}
private func scheduleFlush() {
flushTimer?.invalidate()
flushTimer = Timer.scheduledTimer(withTimeInterval: flushInterval, repeats: false) { [weak self] _ in
guard let self = self, !self.buffer.isEmpty else { return }
self.delegate?.insertPartialText(self.buffer)
self.buffer = ""
}
}
}
30ms 的缓冲窗口是一个精心选择的值:比人眼能感知的延迟(约 100ms)短得多,确保实时感;同时避免了逐 token 插入带来的高频 UI 更新和文本闪烁。
六、持久化:Core Data 三实体模型
Keyfree 使用 Core Data 管理三个核心实体:
- Recording:录音记录,包含音频数据引用、时间戳、转写结果
- ContextSnapshot:上下文快照,记录每次输入时的应用环境
- UserProfile:用户画像,存储长期偏好和使用模式
选择 Core Data 而非 SQLite 直接操作或第三方 ORM,与零依赖原则一致——Core Data 是 Apple 平台原生框架,不需要引入任何额外依赖。
七、BYOAPI 模式的架构影响
Keyfree 采用 BYOAPI(Bring Your Own API)模式,用户使用自己的 API Key 调用多模态 LLM。这个商业决策对架构有直接影响:
- 无需后端:不需要维护认证、计费、代理服务器,整个应用是纯客户端。
- 隐私友好:语音数据直接从用户设备到 LLM 服务商,不经过第三方。
- 成本透明:每次调用约 ¥0.01,用户可以在 LLM 服务商后台看到每一笔消费。
- 多模型支持:架构天然支持切换不同的 LLM 提供商,只要支持多模态音频输入。
八、设计取舍总结
| 决策 | 选择 | 替代方案 | 理由 |
|---|---|---|---|
| 语音处理 | 多模态 LLM 直接处理 | ASR + LLM 两步 | 延迟更低,信息保真度更高 |
| 音频格式 | PCM + Base64 | Opus/AAC 压缩 | 零依赖,实现简单 |
| 文本插入 | 三级降级 | 只用剪贴板 | 最优体验优先,兼容性兜底 |
| 持久化 | Core Data | SQLite / Realm | 零外部依赖 |
| 部署模式 | BYOAPI | SaaS 订阅 | 隐私优先,成本透明 |
每一个决策都指向同一个设计哲学:在 macOS 原生能力的边界内,做到最优的用户体验。
总结
Keyfree 的架构设计展示了一种"少即是多"的工程美学——不追求功能堆砌,而是在有限的约束条件下(零依赖、纯客户端、BYOAPI)做出最优的技术选择。多模态 LLM 直接处理音频的方案,三层上下文系统,三级文本插入降级策略,这些设计决策背后都有清晰的工程逻辑。
项目已在 GitHub 开源(MIT 协议),欢迎感兴趣的开发者阅读源码、提出建议或参与贡献。
- 项目地址:github.com/Maxwin-z/SpeakMore-macOS
- 官网:byutech.cn
- 开发团队:百隅科技(ByuTech)