Local-first AI 工作流:把隐私和可控性放在前面

0阅读11分钟

本地优先是一种产品取舍

我在 2025 年 Q4 开始把自己的日常 AI 辅助编码和笔记处理逐步迁移到本地模型。起因很具体:我负责的一个内部工具需要把客户的合同文本喂给 AI 做摘要提取,合同里有金额、条款和对手方名称。把这些内容发给任何云端 API,从合规角度都不合适。

这迫使我认真评估本地优先 AI 工作流的可行边界。不是「能不能跑起来」的问题——Ollama 装好之后拉个 7B 模型,十分钟就能在终端里跑通 demo。真正的难点在于:本地模型在什么场景下够用、什么时候必须退回云端、数据同步怎么做、资源开销怎么控制。这些问题没有标准答案,但我在反复调试中摸索出了一套可复用的判断框架。

这篇文章记录的就是这套框架。

「Local-first」到底是哪种隐私

2019 年 Martin Kleppmann 在论文《Local-First Software: You Own Your Data, in Spite of the Cloud》中提出了 local-first 的八项理想特性,核心主张是:用户应该拥有自己的数据,应用不应强制依赖服务器,软件应该能在离线状态下正常工作。到 2026 年,FOSDEM 已经开设了 local-first 专属 track,CRDT、Automerge、ElectricSQL 等同步基础设施日趋成熟,local-first 已经从学术概念变成了工程实践。

但 AI 场景下的「local-first」比 Kleppmann 原文讨论的更复杂。Nimbalyst 在 2026 年的一篇对比文章中做了有用的区分:

模式含义数据在哪里模型在哪里典型工具
本地优先数据代码、会话、历史留在本机,模型可以调云端本机 SQLite/文件云端 APICursor Ghost Mode、Pieces
本地模型执行LLM 在本机硬件运行,零字节离开设备本机本机 GPU/CPUOllama、LM Studio、llama.cpp
自托管组织在自有基础设施运行服务端组织内网组织内网 GPU 集群Tabby、Codeium Enterprise

三种模式解决的不是同一个问题。如果你的诉求是「代码不能离开我的电脑」,你需要的是本地模型执行。如果只是「我不想让 AI 公司拿我的历史数据训练」,本地优先数据就够了。如果是公司层面的合规要求,需要的是自托管。混淆这三种模式,是选错方案最常见的原因。

我在合同摘要那个项目里需要的是第一种和第二种兼得:文件不出本机,模型也在本机跑。这直接排除了所有 BYOK(Bring Your Own Key)方案,只剩下 Ollama + llama.cpp 这条路。

端侧推理的工程现实

量化是核心约束

端侧推理能跑起来,完全依赖量化技术。一个 7B 参数的模型,FP16 精度需要约 14GB 显存,普通笔记本放不下。通过 GGUF 格式的 Q4_K_M 量化,同一个模型压缩到约 4.5GB,内存和推理速度都在可接受范围内。

但量化不是免费的。以下是我实际测试中的观察:

模型规模量化方式内存占用代码补全质量复杂推理能力
7BQ4_K_M~4.5GB日常补全够用多步推理经常出错
13BQ4_K_M~8GB明显好于 7B简单架构设计可做
32BQ5_K_M~22GB接近 GPT-4 水平能处理中等复杂度任务
70BQ4_K_M~40GB很强接近未量化大模型

这些数据来自 M3 Max MacBook Pro(36GB 统一内存)上的实测。32B 模型是我在这台机器上能跑的最大可用规格,日常代码补全和文档摘要都够用。70B 模型需要量化到 Q3 才能装进内存,质量下降明显,实际可用性打折扣。

Red Hat 在 2025 年 9 月发布的对比报告中给出了更系统的数据:在 NVIDIA H200 上,vLLM 的峰值吞吐是 llama.cpp 的 35 倍(高并发场景),P99 首 token 延迟在 64 并发下仍然平稳。但这个数据只适用于多用户 GPU 服务场景。对于单用户本地推理,llama.cpp 的 CPU-first 设计反而是优势——它不需要 GPU,在消费级硬件上就能跑。

推理框架选择

维度llama.cppOllamavLLMLM Studio
设计哲学CPU-first 便携开发者体验优先GPU 高吞吐服务桌面 GUI 优先
需要 GPU否(有 GPU 加速)否(有 GPU 加速)
并发支持弱,排队模型中等强,线性扩展单用户
适合场景嵌入式/边缘/离线本地开发/个人使用生产级多用户服务非技术用户试用模型
API 兼容自有格式OpenAI 兼容OpenAI 兼容OpenAI 兼容
量化格式GGUFGGUF(底层 llama.cpp)AWQ/GPTQ/FP16GGUF

Ollama 在 2026 年 Q1 达到 5200 万月下载量,底层就是 llama.cpp。它提供了一层易用的封装,让你用 ollama run qwen3:8b 一行命令就能启动模型。但如果你需要压榨性能——比如在 RTX 5090 上把 Qwen3 Coder 32B 从 Ollama 的 30 tokens/s 提到 llama.cpp 直连的 52 tokens/s——就得绕过 Ollama 直接用 llama.cpp 的 C++ API。

我的选择是:日常开发用 Ollama 图省事,性能敏感的批处理任务走 llama.cpp 的 llama-server

三个实际场景的踩坑记录

场景一:合同摘要的本地流水线

背景:法务同事每周需要处理 20-30 份合同,提取关键条款(金额、期限、违约责任、管辖法院)。数据敏感,不能出内网。

翻车过程:第一次尝试直接用 Ollama 跑 Qwen3 14B 做全文摘要,发现两个问题。第一,合同通常 20-50 页,远超模型的有效上下文窗口——14B 模型虽然声称支持 32K context,但实测超过 8K tokens 后摘要质量急剧下降,经常遗漏后半部分的关键条款。第二,单份合同处理时间约 90 秒,30 份合同串行处理需要 45 分钟,同事等不了。

修复方案:把流水线拆成三步——先用规则做文本分块(按章节标题切分),每块独立提取关键信息,最后用一次汇总调用合并结果。同时把模型从 14B 升级到 32B(Q5_K_M 量化,约 22GB),摘要准确率明显提升。

// ❌ 坏做法:把整份合同直接塞给模型
async function summarizeContractBad(fullText: string) {
  // 全文 20-50K tokens,远超模型有效处理范围
  // 后半部分条款会被遗漏
  const response = await ollama.chat({
    model: 'qwen3:14b',
    messages: [{ role: 'user', content: `提取合同关键条款:\n${fullText}` }],
  })
  return response.message.content
}
 
// ✅ 好做法:分块提取 + 合并
async function summarizeContractGood(fullText: string) {
  // 第一步:按章节分块,每块控制在 4K tokens 以内
  const chunks = splitBySections(fullText, { maxTokens: 4000 })
 
  // 第二步:逐块提取关键信息,并行处理
  const extractions = await Promise.all(
    chunks.map(chunk =>
      ollama.chat({
        model: 'qwen3:32b',
        messages: [{
          role: 'user',
          content: `从以下合同片段中提取:金额、期限、违约责任、管辖法院。
未提及的字段返回 null。\n\n${chunk}`,
        }],
      })
    )
  )
 
  // 第三步:合并去重,处理冲突
  return mergeExtractions(extractions.map(r => r.message.content))
}

关键差异在于:分块处理绕过了上下文窗口限制,32B 模型提升了提取准确率,并行处理把总耗时从 45 分钟压到约 8 分钟。

场景二:本地代码补全的质量边界

背景:我想在离线环境(飞行模式)下也有可用的代码补全。之前用云端 API,延迟和准确率都很好。切到本地后差距明显。

翻车过程:最初用 Qwen3 Coder 7B 做补全,单行补全(补全一个函数参数、一行 if 条件)质量不错,但多行补全经常跑偏——比如我写了一个 async function fetchUser(id: string),它补全的函数体经常用错 API 路径或者编造不存在的类型。更麻烦的是上下文感知能力:7B 模型对当前项目其他文件的代码结构基本没有感知,补全的变量名和已有代码风格不一致。

修复方案:做两件事。第一,换 32B 模型,多行补全质量直接上了一个台阶。第二,实现一个轻量的 RAG——在本地建一个当前项目文件的 embedding 索引,补全时把相关文件片段作为上下文注入 prompt。

# ❌ 坏做法:只传当前文件内容作为上下文
def complete_bad(current_file: str, cursor_pos: int):
    prefix = current_file[:cursor_pos]
    suffix = current_file[cursor_pos:]
    # 模型对项目的其他代码一无所知
    response = ollama.generate(
        model='qwen3-coder:7b',
        prompt=f'{prefix}\n// 继续补全:'
    )
    return response['response']
 
# ✅ 好做法:注入相关文件作为上下文
def complete_good(current_file: str, cursor_pos: int, project_index):
    prefix = current_file[:cursor_pos]
    suffix = current_file[cursor_pos:]
 
    # 用当前文件的关键标识符(函数名、类型名、import 路径)
    # 检索项目中最相关的 3-5 个代码片段
    relevant_context = project_index.search(
        extract_symbols(prefix + suffix),
        top_k=4
    )
 
    prompt = f"""以下是项目中的相关代码:
{format_context(relevant_context)}
 
当前文件内容:
{prefix}
// 继续补全:"""
 
    response = ollama.generate(
        model='qwen3-coder:32b',
        prompt=prompt,
        options={'num_ctx': 8192}
    )
    return response['response']

这个本地 RAG 实现很粗糙——用 sentence-transformers 做 embedding,结果存 SQLite + 余弦相似度检索。但它让 32B 模型的补全准确率从「勉强能用」提升到「大多数时候不需要手动改」。

场景三:同步冲突的处理

背景:我在三台设备上工作——办公室的 MacBook Pro、家里的 M2 Mac mini、出差时的 ThinkPad。本地优先意味着每台设备有自己的模型和索引,数据需要跨设备同步。

翻车过程:第一版同步方案直接用 Syncthing 同步整个 Ollama 模型目录和 SQLite 数据库。问题出在两台设备同时修改了同一个笔记文件时——Syncthing 产生了 .sync-conflict 文件,但 AI 生成的摘要和原始笔记之间的引用关系断了。更严重的是 embedding 索引:两台设备各自建了不同的索引,同步后索引和实际文件内容对不上,搜索结果开始出现不相关的条目。

修复方案:分两层解决。文件层用 Automerge(基于 CRDT 的文档合并库),文本冲突可以自动合并。索引层不跨设备同步——每台设备同步完文件后,用 file watcher 触发本地增量重建索引。AI 生成的内容(摘要、标注)带上设备 ID 和时间戳,合并时按时间戳取最新的。

// ❌ 坏做法:直接同步索引数据库
// 两台设备的索引合并后不一致,搜索结果出错
function syncIndexBad(localDb: Database, remoteDb: Database) {
  // 直接复制行,忽略索引与文件内容的关联关系
  remoteDb.indices.forEach(idx => {
    localDb.indices.insert(idx) // 可能产生重复或过时索引
  })
}
 
// ✅ 好做法:只同步文件,索引各自重建
function syncIndexGood(fileSync: FileSyncService, indexBuilder: IndexBuilder) {
  // 监听文件变更事件
  fileSync.on('change', async (event) => {
    const { filePath, source } = event
 
    // AI 生成内容带元数据,合并时按时间戳选择
    if (event.hasConflict) {
      const merged = automerge.merge(event.local, event.remote)
      // 合并规则:AI 摘要取时间戳最新的版本
      const latestSummary = [merged.localSummary, merged.remoteSummary]
        .filter(Boolean)
        .sort((a, b) => b.timestamp - a.timestamp)[0]
      await fileSync.writeFile(filePath, { ...merged, summary: latestSummary })
    }
 
    // 文件稳定后增量重建索引
    // 只处理变更文件,不做全量重建
    await indexBuilder.rebuildForFile(filePath)
  })
}

核心原则是:文件是同步的原生类型,索引是文件的派生物,永远不要直接同步派生数据。

本地优先 AI 的系统架构

把上面的经验抽象出来,一个完整的 local-first AI 工作流长这样:

流程图画布 · 115%
Mermaid 流程图加载中...

关键设计决策在「任务路由」这一层。不是所有任务都适合本地模型——我在实际使用中,大约 80% 的日常补全和摘要任务在本地完成,20% 需要复杂推理或处理多模态内容的任务走云端。路由规则可以很简单:检查网络状态(离线强制走本地)、检查内容敏感度(标记为敏感的不出去)、检查任务复杂度(prompt 超过一定长度或涉及多步推理的走云端)。

四种方案的对比

维度纯本地模型本地数据 + 云端模型自托管服务纯云端
数据隐私最强,零字节离开设备中等,数据发给模型提供商强,数据不出内网弱,依赖提供商策略
模型能力受限于量化和设备资源最强,始终用前沿模型可选,取决于部署的模型最强
离线可用完全可用不可用内网可用不可用
初始成本高(需购买硬件)高(服务器 + GPU)
边际成本趋近于零按 token 计费固定运维成本按 token 计费
适合谁个人开发者/隐私极端敏感大多数开发者日常使用企业/团队无特殊要求的场景

多数人在 2026 年的实际选择是第二种——本地存储文件和会话,日常补全走本地模型,困难任务用 BYOK 调 Claude 或 GPT。这不是妥协,是合理的工程取舍。

资源开销的精确控制

端侧推理最大的实际障碍是资源占用。一个 32B 模型常驻内存 22GB,如果开发环境本身需要 16-20GB(浏览器 + IDE + Docker),36GB 内存的机器刚好打满,开始 swap,推理速度也会下降。

策略做法效果代价
按需加载只在触发补全时加载模型,空闲超时卸载内存占用降到 ~0首次响应延迟 5-15 秒
小模型常驻 + 大模型按需7B 模型常驻处理简单补全,复杂任务临时加载 32B平衡延迟和资源需要两层模型管理逻辑
CPU offload部分层放到 CPU,减少 GPU 内存占用降低 GPU 显存需求推理速度下降 30-50%
量化降级从 Q5 降到 Q4 甚至 Q3内存占用降 20-40%质量有可见下降
# ❌ 坏做法:让模型常驻不释放,资源一直占满
ollama run qwen3:32b
# 启动后占 22GB 内存,即使长时间不用也不释放
 
# ✅ 好做法:设置空闲超时,自动卸载模型
# 在 ollama serve 的环境变量中设置
OLLAMA_KEEP_ALIVE=5m  # 空闲 5 分钟后卸载模型
# 或者在 API 调用时指定
curl http://localhost:11434/api/generate -d '{
  "model": "qwen3:32b",
  "prompt": "...",
  "keep_alive": "2m"
}'
 
# ✅ 更好的做法:用小模型常驻 + 大模型按需
# 常驻 7B 模型处理自动补全(占 4.5GB)
ollama run qwen3:8b --keep-alive 30m
 
# 复杂任务临时加载 32B
curl http://localhost:11434/api/generate -d '{
  "model": "qwen3:32b",
  "prompt": "...",
  "keep_alive": "1m"
}'

选型检查清单

在决定 local-first AI 方案之前,按以下清单逐项确认:

需求定义阶段

  • 明确数据敏感度等级:哪些数据绝对不能离开设备,哪些可以走云端
  • 确认离线需求比例:有多少工作时间处于无网络或部分网络状态
  • 评估模型能力需求:日常任务(补全、摘要、分类)vs 复杂任务(架构设计、多步推理)的比例
  • 确认合规要求:HIPAA、GDPR、等保等法规对数据处理的具体约束
  • 确定并发需求:单人使用 vs 团队使用 vs 企业级部署

硬件评估阶段

  • 确认设备可用内存(RAM):减去开发环境常驻后的剩余容量决定可运行的最大模型
  • 确认 GPU 情况:有独立 GPU(NVIDIA/Apple Silicon)可以加速,没有也能跑(CPU 模式)
  • 确认磁盘空间:模型文件通常 4-40GB,需要预留空间
  • 如果需要多设备工作,确认同步方案(CRDT vs 文件同步 vs 手动)

工具选型阶段

  • 根据隐私模型(本地数据/本地模型/自托管)缩小工具范围
  • 测试候选模型在实际任务上的质量,不要只看 benchmark
  • 确认工具是否支持 BYOK 混合模式,避免被锁死在纯本地或纯云端
  • 评估运维成本:模型更新、索引重建、同步冲突处理的自动化程度
  • 做一个为期一周的 dry run,记录质量不满足预期的场景,确认是否有降级/升级路径

部署运维阶段

  • 设置模型空闲超时(OLLAMA_KEEP_ALIVE),避免资源浪费
  • 建立索引增量重建机制,不做全量重建
  • 配置文件变更监控,确保索引与内容一致
  • 设置混合路由规则:离线/敏感 → 本地,复杂/多模态 → 云端
  • 定期对比本地模型和云端模型在你常用任务上的质量差距,评估是否需要升级硬件或调整模型规格

收束

回到最开始那个合同摘要的需求。最终方案是 Ollama + Qwen3 32B,分块提取流水线跑在本地,三台设备通过 CRDT 同步文件,索引各自重建。法务同事反馈摘要准确率约 90%,偶尔需要手动补充——比纯手工阅读快了 3 倍。

这套方案不完美。32B 模型在复杂条款推理上仍然不如 GPT-4,70B 模型在我的内存预算内跑不起来,多模态内容(合同里的表格、签章图片)本地模型基本处理不了。这些是当前硬件和模型能力的真实限制,不是架构设计能解决的。

但本地优先的核心价值不在于「什么都能在本地做」,而在于「至少有一个可靠的选择在本地」。当数据不能出去的时候,你有能力在本地完成工作,而不是被迫在合规和效率之间二选一。这个选项本身,就是 local-first 最大的意义。

参考资料

评论 0

0 / 1000