小模型回到本地:NPU、端侧推理和开发者的新耐心
古董级程序员,大厂出来后一直在创业公司,现在仍在一线做 AI 相关的工程。更完整的技术记录写在微信公众号「字与码」:工作经历、对新工具的看法,以及这些年踩过的坑,会不定期发在那里。若这篇对你有用,欢迎顺手关注。
我对端侧小模型的兴趣,不是来自“把大模型塞进手机”这类口号,而是来自一个很具体的需求:做一个离线会议助手。它要在笔记本和手机上工作,能在没有网络的会议室里生成本地摘要,能先把敏感信息在设备上处理掉,必要时再把脱敏后的材料交给云端兜底。
这个需求听起来不大,却能把端侧 AI 的难点都翻出来:模型下载、冷启动、NPU 和 CPU fallback、音频转写后的本地摘要、隐私缓存、云端兜底开关、设备碎片化、发布灰度、回滚和验收。端侧小模型不是云端大模型的迷你版,它更像一个必须长期住在用户设备里的功能模块。
下面我用这个离线会议助手做主线,记录一套可以复制的工程方案。它不是某个真实产品的内部实现,所有名称、URL、token 和客户信息都用公开示例替代。

先把产品边界写窄
离线会议助手第一版只承诺四件事。
它能在本地处理一段会议音频转写文本,生成 5 到 8 条要点。它能识别并遮蔽明显的手机号、邮箱、身份证样式字段、合同号样式字段。它能在用户同意后,把脱敏摘要和片段索引发给云端,让云端生成更完整的纪要。它能在没有网络时保存草稿,等用户确认后再同步。
它不承诺自动识别所有发言人,不承诺做法律级事实核验,不承诺离线回答最新政策,也不承诺把两小时会议完整压进一个小模型上下文。边界写窄不是保守,而是端侧产品必须对资源和信任负责。
第一版的本地链路是这样的:
音频文件或实时转写文本
-> 本地分段器
-> 隐私预处理器
-> 小模型摘要器
-> 本地草稿和片段索引
-> 用户确认
-> 可选云端兜底
这里的小模型不是唯一主角。分段、脱敏、缓存、权限和兜底一样重要。端侧 AI 做得好,常常不是因为模型特别聪明,而是每一步都知道自己该做多少。
一个请求长什么样
本地接口没有必要模仿云端 OpenAI API,但保留结构化请求会让调试和回放方便很多。会议助手内部给本地推理服务发这样的请求:
{
"request_id": "local_meeting_20260615_001",
"mode": "offline_summary",
"device_context": {
"platform": "macos",
"power": "battery",
"network": "offline",
"accelerator": "ane",
"memory_pressure": "normal"
},
"privacy_policy": {
"redact_before_cache": true,
"allow_cloud_fallback": false,
"retention_hours": 24
},
"input": {
"meeting_title": "产品周会",
"language": "zh-CN",
"segments": [
{
"start_ms": 12000,
"end_ms": 42600,
"speaker": "S1",
"text": "我们本周先把离线摘要做成 beta,不要默认上传音频。"
},
{
"start_ms": 43100,
"end_ms": 86500,
"speaker": "S2",
"text": "如果用户打开云端增强,只上传脱敏后的摘要和片段索引。"
}
]
},
"output": {
"max_bullets": 8,
"include_actions": true,
"include_risks": true
}
}
返回也要能解释本地做了什么:
{
"request_id": "local_meeting_20260615_001",
"status": "ok",
"summary": [
"离线摘要 beta 阶段不默认上传音频。",
"云端增强需要用户主动开启,并且只接收脱敏后的摘要和片段索引。"
],
"actions": [
{
"owner": "未识别",
"text": "补充云端增强的授权弹窗文案。",
"evidence_segment_ids": [2]
}
],
"redactions": [
{
"type": "email",
"count": 1,
"replacement": "[EMAIL_1]"
}
],
"runtime": {
"model": "meeting-summarizer-1.8b-q4",
"backend": "ane",
"input_tokens": 1462,
"output_tokens": 214,
"prefill_ms": 382,
"decode_ms": 1040,
"peak_memory_mb": 1180,
"cloud_fallback_used": false
}
}
这类结构化返回有两个好处。产品可以把“云端未使用”“已脱敏”“本地草稿保存 24 小时”清楚地展示给用户;工程也可以在不记录原文的情况下知道模型是否跑在加速器上、耗时多少、是否触发 fallback。
设备能力探测比模型榜单更早
端侧项目最容易犯的错,是先挑一个看起来分数不错的模型,再想办法让它跑起来。会议助手反过来做。先定义设备矩阵,再选模型。
第一版只覆盖三类设备:
| 设备档位 | 目标 | 允许策略 |
|---|---|---|
| 高端笔记本 | 10 分钟会议 15 秒内出草稿 | 本地摘要、脱敏、可选云端增强 |
| 普通手机 | 10 分钟会议 30 秒内出草稿 | 本地短摘要、低电量降级 |
| 低端或旧设备 | 只做隐私预处理和片段索引 | 不强行本地生成长摘要 |
设备探测在功能入口就发生,而不是推理失败后才补救:
{
"device_id_hash": "dev_anon_7f2a",
"platform": "android",
"os_version": "15",
"app_version": "1.4.0",
"accelerators": ["nnapi", "cpu"],
"recommended_backend": "nnapi",
"available_memory_mb": 3260,
"battery_level": 0.42,
"thermal_state": "nominal",
"model_profile": "meeting-lite"
}
如果设备只能稳定跑 meeting-lite,界面就不要给用户承诺“完整纪要”。可以给“本地要点”和“云端增强”两个清晰选择。端侧 AI 的体验很大一部分来自诚实,不是来自把所有能力都塞进一个按钮。
模型包不只是一个文件
会议助手用了两个本地模型包。一个很小,负责隐私预处理和意图分类;一个稍大,负责会议摘要草稿。它们和 tokenizer、prompt 模板、后处理规则绑定发布。
模型清单像这样:
models:
- id: privacy-filter-280m-q8
role: privacy_preprocess
size_mb: 210
min_app_version: "1.4.0"
backends: ["ane", "nnapi", "cpu"]
checksum: "sha256:example-privacy-filter"
retention: bundled
- id: meeting-summarizer-1.8b-q4
role: offline_summary
size_mb: 1260
min_app_version: "1.4.0"
backends: ["ane", "nnapi", "directml", "cpu"]
checksum: "sha256:example-meeting-summarizer"
download:
wifi_only: true
resume: true
keep_previous_version: true
prompts:
summary_template_version: "summary-v6"
action_template_version: "action-v3"
postprocess:
schema_version: "meeting-note-v2"
这里最容易被低估的是版本绑定。新模型可能要求新的 tokenizer,也可能改变输出格式。只替换权重文件,不更新 prompt 和后处理,很容易让客户端解析失败。端侧发布比服务端更麻烦,因为坏版本已经到了用户设备上,不是重启一个 Pod 就能消失。
所以模型包必须有签名、校验、最小 App 版本、可回滚的上一版本。下载也要可恢复,不能让用户在会议前卡在一个 1GB 文件上。
本地摘要不是把整场会议塞进去
小模型上下文有限,移动设备更有限。会议助手不把整场会议一次性喂给模型,而是先做分段,再做局部摘要,最后合并。一个 45 分钟会议可能被拆成 20 到 40 个片段。
分段器用简单规则和轻量模型混合:静音超过一定时间、话题关键词变化、发言人变化、片段 token 超过上限。每段生成一个局部草稿:
{
"segment_id": 12,
"time_range": "00:18:22-00:21:05",
"input_tokens": 1180,
"local_summary": "团队决定 beta 阶段默认只保存本地草稿,云端增强需要用户主动确认。",
"actions": [
"补充授权弹窗",
"把云端增强的上传字段写入隐私说明"
],
"risk_flags": ["privacy_notice_required"]
}
全局摘要只读取这些局部草稿和少量证据片段,而不是读取完整原文。这样做牺牲了一部分跨段推理能力,但换来稳定的内存、可解释的证据和更低的隐私风险。
端侧小模型适合做“足够好的草稿”,不是替人拍板。会议纪要这种场景尤其要保留证据片段,让用户能点回去看原话。没有证据的漂亮总结,在线上看起来聪明,在实际工作里很危险。
隐私预处理在缓存之前
“数据不出设备”不等于隐私问题自动解决。会议助手会在本地缓存草稿、片段索引和模型中间结果。如果缓存里保留了手机号、邮箱、合同号,风险仍然在设备上长期存在。
所以我们把隐私预处理放在缓存之前。原始转写文本只保存在受保护的临时区,默认 24 小时过期。进入持久缓存的内容必须先脱敏。
privacy:
raw_transcript:
storage: protected_tmp
ttl_hours: 24
sync: false
redacted_segments:
storage: encrypted_local_db
ttl_days: 30
sync: optional
cloud_payload:
allowed_fields:
- redacted_summary
- action_items
- evidence_segment_ids
- language
denied_fields:
- raw_audio
- raw_transcript
- speaker_voiceprint
脱敏规则不要只靠大模型。确定性规则负责邮箱、手机号、证件号样式、银行卡样式;小模型负责识别“这可能是客户名称、内部项目代号、合同编号”这类上下文信息。两者都不完美,所以界面上要允许用户查看将要上传的内容。
云端兜底的请求因此长这样:
{
"request_id": "cloud_enhance_20260615_001",
"consent_id": "consent_local_9a21",
"source": "offline_meeting_assistant",
"payload": {
"language": "zh-CN",
"redacted_summary": [
"团队决定 beta 阶段默认只保存本地草稿。",
"[PROJECT_1] 需要补充授权弹窗和隐私说明。"
],
"action_items": [
"补充授权弹窗",
"确认云端增强上传字段"
],
"evidence_segment_ids": [3, 12, 18]
}
}
注意这里没有原始音频、没有完整转写、没有真实人名邮箱。云端拿到的是本地预处理后的材料。这个链路不如“全量上云”聪明,但它更容易被用户和企业安全团队接受。
云端兜底是正常路径,不是失败路径
很多端侧方案把云端兜底写成“本地失败才调用云端”。会议助手里不这么设计。云端增强是一条用户可见的正常路径:本地先出草稿,用户看过脱敏内容后,选择是否增强。
本地模式下,用户得到的是要点、待办和风险提示。云端增强模式下,用户可以得到更完整的章节、决策背景、冲突点整理和格式化纪要。二者不是谁替代谁,而是成本、隐私和质量的不同选择。
工程上也不能做双倍浪费。云端增强不从零处理会议,而是接收本地摘要、待办、片段索引和少量已脱敏证据。这样云端上下文更短,费用更低,用户也知道上传了什么。
如果企业策略禁止云端,功能仍然可用,只是不展示增强入口:
{
"policy": "local_only",
"features": {
"offline_summary": true,
"privacy_preprocess": true,
"cloud_enhance": false,
"share_redacted_note": true
},
"message_code": "cloud_disabled_by_policy"
}
这个错误语义比“网络不可用”重要。用户需要知道是离线、策略禁止、低电量降级,还是设备不支持。
指标只记录运行状态,不记录会议内容
端侧 AI 也需要观测,否则线上问题会变成客服截图。难点是不能把用户会议内容传回去。会议助手只上报匿名运行指标和失败原因。
第一版指标字段如下:
| 字段 | 用途 |
|---|---|
event | model_loaded、summary_completed、fallback_used 等 |
app_version | 排查客户端版本问题 |
model_id | 模型版本和量化版本 |
backend | ane、nnapi、directml、cpu |
device_tier | 高端、中端、低端的匿名档位 |
network_state | 在线、离线、弱网 |
battery_state | 插电、电池、低电量 |
thermal_state | 正常、温热、过热降级 |
input_token_bucket | 只记录分桶,不记录文本 |
latency_ms | 端到端耗时 |
prefill_ms | 输入处理耗时 |
decode_ms | 生成耗时 |
peak_memory_mb | 峰值内存 |
fallback_reason | CPU 回退、模型缺失、策略禁止等 |
redaction_count_bucket | 脱敏数量分桶,不含具体值 |
一条上报像这样:
{
"event": "summary_completed",
"app_version": "1.4.0",
"model_id": "meeting-summarizer-1.8b-q4",
"backend": "nnapi",
"device_tier": "mid",
"network_state": "offline",
"battery_state": "battery",
"thermal_state": "nominal",
"input_token_bucket": "8k-16k",
"latency_ms": 23840,
"prefill_ms": 6920,
"decode_ms": 11860,
"peak_memory_mb": 1420,
"fallback_reason": "none",
"cloud_fallback_used": false
}
这里故意不上传会议标题、发言人、摘要文本、具体脱敏值。端侧功能的观测要克制,宁可少一点,也不要把信任优势毁掉。
排障记录:为什么一台手机突然慢了三倍
试点时遇到过一个典型问题:同一个 10 分钟会议样本,高端笔记本 11 秒出草稿,中端手机有时 24 秒,有时接近 70 秒。模型没变,输入也没变。
排查不是从模型质量开始,而是从指标分桶开始。慢请求有三个共同点:backend=cpu、thermal_state=warm、fallback_reason=accelerator_compile_failed。也就是说,NNAPI 编译失败后回退到了 CPU,手机又处于温热状态,频率下降,延迟自然翻倍。
继续查本地日志,发现失败集中在一个算子组合上。模型里有一段动态 shape,对某个驱动版本不稳定。解决方式不是让用户重试,而是给这类设备下发 meeting-lite 配置,限制片段长度,并禁用触发问题的图优化。
配置像这样:
device_overrides:
- match:
platform: android
accelerator: nnapi
driver_family: "example-driver-31"
model_profile: meeting-lite
max_segment_tokens: 900
graph_optimizations:
dynamic_shape_fusion: false
fallback:
allow_cpu: true
max_latency_ms: 45000
这个问题说明端侧排障有自己的节奏。服务端慢了,可以看 Pod、看 GPU、看队列。端侧慢了,要看设备温度、驱动、后端、内存压力、是否插电、是否刚下载完模型。没有匿名指标,团队只能在少数测试机上猜。
发布:模型灰度要像客户端灰度
会议助手的发布不是把模型文件放到 CDN 就结束。第一版发布分成四层:客户端代码、模型清单、模型文件、远程策略。任何一层都可能需要回滚。
一个可执行的发布步骤如下。
先发布客户端 1.4.0,只带隐私过滤小模型,摘要模型按需下载。功能入口默认对 5% beta 用户开放。
rollout:
app_min_version: "1.4.0"
audience: beta
percent: 5
default_mode: local_summary
cloud_enhance_default: off
再下发模型清单,但不自动下载大模型。用户进入会议助手时,如果设备符合条件且在 Wi-Fi 下,才提示下载。
download_policy:
meeting-summarizer-1.8b-q4:
wifi_only: true
require_battery_level_gte: 0.3
allow_metered_network: false
keep_previous_version: true
接着扩大到 20%,观察模型加载成功率、下载失败率、摘要完成率、CPU fallback 比例、低电量降级比例。这里的核心不是 DAU,而是端侧运行健康。
最后才开放云端增强。云端增强必须单独灰度,因为它牵涉授权文案、脱敏 payload、服务端成本和企业策略。
cloud_enhance:
percent: 10
require_user_consent: true
upload_payload: redacted_only
max_payload_tokens: 6000
blocked_when:
- enterprise_policy_local_only
- redaction_confidence_low
这个节奏慢一些,但端侧功能需要这种耐心。坏模型一旦下载到大量设备,回收成本比服务端高得多。
回滚:用户设备上的坏版本不会自动消失
端侧回滚分四种情况。
如果只是策略问题,比如云端增强授权文案不够清楚,关闭远程开关即可:
features:
cloud_enhance:
enabled: false
reason: "consent_copy_revision"
如果是模型质量问题,把模型清单指回上一版,并保留已下载的新模型但不再选择它。不要立刻删除,避免用户在弱网下反复下载。
models:
meeting_summary_active: meeting-summarizer-1.8b-q4-20260610
meeting_summary_blocked:
- id: meeting-summarizer-1.8b-q4-20260615
reason: "action_item_regression"
如果是某类设备的运行时问题,只对该设备族降级到 lite 模型或 CPU 路径。不要全量回滚,否则会牺牲其他设备上已经稳定的体验。
device_overrides:
- match:
device_tier: mid
backend: nnapi
os_version_lte: "15"
force_model: meeting-summarizer-lite-900m-q4
如果是客户端解析崩溃,才需要紧急发版,并通过远程策略关闭相关模型能力。端侧系统要默认假设“有一部分用户暂时不会升级”,所以旧客户端兼容性要保留一段时间。
回滚演练也要做。验收不是写一段文档,而是在测试设备上真的把模型从新版本切回旧版本,确认草稿还能打开,缓存不会损坏,用户不会丢会议记录。
验收:端侧 AI 要看体验,也要看不打扰
会议助手第一版的验收线写得很具体。
| 指标 | 验收标准 |
|---|---|
| 高端笔记本 10 分钟会议草稿 | P95 小于 15 秒 |
| 中端手机 10 分钟会议草稿 | P95 小于 30 秒 |
| 本地隐私预处理 | 邮箱、手机号样式召回率大于 99% |
| 云端 payload | 不包含原始音频和完整原文 |
| 模型冷启动 | 热启动小于 2 秒,冷启动小于 8 秒 |
| CPU fallback 比例 | 支持设备上小于 5% |
| 低电量降级 | 电量低于 20% 时不启动大模型 |
| 崩溃率 | beta 用户不高于基线 0.1 个百分点 |
| 回滚耗时 | 远程策略 5 分钟内生效 |
| 用户可见控制 | 能查看本地保存、上传内容和清除缓存 |
质量验收也不能只看自动分数。我们准备了一组会议样本:短站会、产品评审、客户访谈、技术排障、带大量数字的项目同步。每个样本都有人类参考摘要,检查三类问题:关键决策有没有漏,待办有没有编造,敏感字段有没有外泄。
端侧还有一个特殊验收:不打扰。模型下载不能抢用户流量,低电量时不能硬跑,手机发热时不能继续生成长摘要,用户关闭云端增强后不能偷偷上传。很多 AI 功能失败在太积极,而端侧功能尤其要学会克制。
这套方案真正改变了什么
做完这个离线会议助手,我对端侧小模型的判断更朴素了。
它不是云端大模型的替代品。它更适合做第一层:离数据近、响应快、隐私敏感、可以离线、成本稳定。它负责把原始材料变成更干净、更短、更有结构的中间结果。云端负责更复杂的推理和更完整的表达,但前提是用户知道并同意哪些数据会离开设备。
开发上也要换心态。服务端 AI 项目常常围绕吞吐、队列和 GPU 成本转;端侧 AI 项目则绕不开电量、温度、包体、下载、驱动、缓存、权限和回滚。NPU 不是自动加速按钮,小模型也不是随便放进 App 就能用。
最可靠的路径是从一个窄场景开始。比如会议助手只先做好本地摘要、隐私预处理和云端增强确认。把请求样例、配置、指标、排障、发布、回滚和验收都跑通以后,再谈更多能力。端侧 AI 最后拼的不是一句“本地运行”,而是它在用户设备上长期、稳定、可解释地运行。
当一个功能能在断网时给出可用草稿,在联网时清楚说明上传内容,在低电量时主动降级,在出问题时能回滚到旧模型,我才会觉得它真的进入了产品阶段。否则它只是一次演示。