跳到正文
跟我做
开发者 AI 实战6 分钟Ollama

断网也能批量处理:用本地模型做摘要、分类和打标签

用 Ollama 在本机跑一个 7B 级模型,配合一段 Node 脚本批量处理访谈、评论和文章,结果校验后落盘,素材不出本机。

约 6 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 先摸清本地模型的能力边界
  3. 准备:模型、内存和接口
  4. 定义一个能被校验的输出
  5. 批量脚本
  6. 提示词怎么写才稳定
  7. 结果校验与兜底
  8. 断点续跑和进度
  9. 速度和并发
  10. 隐私与安全检查
  11. 什么时候该换回云端
  12. 下一步读什么

30 秒速查

  • 装模型:ollama pull qwen2.5:7b
  • 输入 JSONL,每行至少 id 和 text
  • 批量:node local-llm-batch.mjs items.jsonl results.jsonl
  • 先跑 20 条看质量,敏感素材不要上传

这篇教程解决什么问题

手里有一批访谈记录、用户评论或文章,想批量做三件事:写一句摘要、打上标签、判断情绪倾向。把这些内容上传到在线服务当然最快,但素材可能包含客户信息、未发布的产品细节,或者你只是不想为一次性的整理工作再开一个 API 账户。

这篇用 Ollama 在本机跑一个 7B 级模型,配一段 Node 脚本完成批量处理。重点是三件事:

  • 输出是固定结构,不是一段自由发挥的文字;
  • 每条结果都经过校验,不合格的单独标出来;
  • 中途断了可以接着跑,不会重复处理已经完成的条目。

先摸清本地模型的能力边界

小模型不是云端大模型的平替。下面这些判断,决定了你会不会白忙一场:

任务 7B 级本地模型 说明
一句话摘要 可以 短文本、明确字数限制时稳定
从固定标签里选 可以 标签越少、定义越清楚,越稳
情感倾向三分类 基本可以 反讽和混合情绪容易出错,需要人工抽查
多步推理、算数 不可靠 不要用它做金额、日期、逻辑链计算
长文档整体理解 不可靠 上下文有限,且长上下文会拖慢速度

结论:把任务拆小,把输出收紧。一次只让它做一件判断,比让它“顺便分析一下”成功率高得多。

准备:模型、内存和接口

先确认本机已经装好 Ollama,并拉一个中文能力尚可的小模型。下面只是示例,模型版本会更新,按自己机器的内存选择:

ollama pull qwen2.5:7b
ollama run qwen2.5:7b "用一句话解释什么是向量数据库"

拉取完成后,看一眼模型和运行状态:

ollama list
ollama ps

Ollama 默认在 127.0.0.1:11434 提供 HTTP 接口。我们的脚本直接调 /api/chat,不需要安装任何 SDK。

内存的粗略估算:

  • 7B 的 4-bit 量化模型,权重约 4 到 5GB;
  • 加上上下文和运行时开销,留 8GB 以上可用内存比较稳;
  • 14B 模型大约翻倍,CPU 跑会明显变慢;
  • 上下文开得越大,占用越高,不要无脑调大。

定义一个能被校验的输出

先别写提示词,先定结果长什么样。我们要的每条记录是:

{
  "summary": "不超过 60 个汉字的摘要",
  "tags": ["选题"],
  "sentiment": "neutral",
  "reason": "判断依据,一句话"
}

约束越具体,后面越好校验:

  • summary 有长度上限;
  • tags 只能从白名单里选,最多两个;
  • sentiment 只有三个枚举值;
  • reason 用来抽查模型是不是在胡说。

批量脚本

新建 local-llm-batch.mjs。输入是一行一条的 JSONL,每行至少包含 id 和 text:

{"id":"c-001","text":"这条视频的第三步讲得太快了,希望能拆开再讲一次。"}
{"id":"c-002","text":"跟着做了封面模板,十分钟出了二十张,省了很多时间。"}

脚本如下:

import { readFile, appendFile } from "node:fs/promises";

const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://127.0.0.1:11434";
const MODEL = process.env.OLLAMA_MODEL ?? "qwen2.5:7b";
const INPUT = process.argv[2] ?? "items.jsonl";
const OUTPUT = process.argv[3] ?? "results.jsonl";
const FAILED = OUTPUT.replace(/\.jsonl$/, "") + ".failed.jsonl";

const TAGS = ["选题", "产品反馈", "使用问题", "合作", "闲聊"];
const MAX_CHARS = 6000;

let writeChain = Promise.resolve();

function appendRecord(file, record) {
  writeChain = writeChain.then(() =>
    appendFile(file, JSON.stringify(record) + "\n"),
  );
  return writeChain;
}

async function loadDone(file) {
  try {
    const text = await readFile(file, "utf8");
    return new Set(
      text
        .split("\n")
        .filter(Boolean)
        .map((line) => JSON.parse(line).id),
    );
  } catch (error) {
    if (error.code === "ENOENT") return new Set();
    throw error;
  }
}

async function classify(item) {
  const response = await fetch(`${OLLAMA_URL}/api/chat`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      model: MODEL,
      stream: false,
      format: "json",
      keep_alive: "10m",
      options: {
        temperature: 0,
        num_ctx: 4096,
      },
      messages: [
        {
          role: "system",
          content: [
            "你是一个内容运营助手。只输出 JSON,不要解释,不要 Markdown。",
            '结构:{"summary": string, "tags": string[], "sentiment": "positive" | "neutral" | "negative", "reason": string}',
            "summary 不超过 60 个汉字,只概括原文,不要加入原文没有的信息。",
            `tags 最多 2 个,只能从这些值里选:${TAGS.join("、")}。`,
            "无法判断时 sentiment 用 neutral,并在 reason 里说明原因。",
          ].join("\n"),
        },
        { role: "user", content: item.text.slice(0, MAX_CHARS) },
      ],
    }),
    signal: AbortSignal.timeout(120_000),
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`HTTP ${response.status}: ${body.slice(0, 200)}`);
  }

  const data = await response.json();

  return {
    raw: data.message?.content ?? "",
    durationMs: Math.round((data.total_duration ?? 0) / 1_000_000),
  };
}

function parseResult(raw) {
  const value = JSON.parse(raw);

  const summary = typeof value.summary === "string" ? value.summary.trim() : "";
  const tags = Array.isArray(value.tags)
    ? value.tags.filter((tag) => TAGS.includes(tag)).slice(0, 2)
    : [];
  const sentiment = ["positive", "neutral", "negative"].includes(value.sentiment)
    ? value.sentiment
    : "neutral";

  return {
    summary,
    tags,
    sentiment,
    reason: typeof value.reason === "string" ? value.reason.trim() : "",
    needsReview: summary.length === 0 || summary.length > 80 || tags.length === 0,
  };
}

async function mapLimit(items, limit, worker) {
  let cursor = 0;

  async function run() {
    while (cursor < items.length) {
      const index = cursor;
      cursor += 1;
      await worker(items[index]);
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(limit, items.length) }, () => run()),
  );
}

async function main() {
  const items = (await readFile(INPUT, "utf8"))
    .split("\n")
    .filter(Boolean)
    .map((line) => JSON.parse(line));

  const done = await loadDone(OUTPUT);
  const queue = items.filter((item) => !done.has(item.id));

  console.log(`共 ${items.length} 条,已完成 ${done.size},本次处理 ${queue.length}`);

  let ok = 0;
  let review = 0;
  let failed = 0;

  await mapLimit(queue, 2, async (item) => {
    try {
      const { raw, durationMs } = await classify(item);
      const result = parseResult(raw);

      await appendRecord(OUTPUT, {
        id: item.id,
        ...result,
        durationMs,
        at: new Date().toISOString(),
      });

      if (result.needsReview) review += 1;
      else ok += 1;
    } catch (error) {
      failed += 1;

      await appendRecord(FAILED, {
        id: item.id,
        error: String(error.message ?? error),
        at: new Date().toISOString(),
      });
    }
  });

  console.log({ ok, review, failed });
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

运行:

node local-llm-batch.mjs items.jsonl results.jsonl

提示词怎么写才稳定

这段脚本里,提示词做了四件事:

  1. 限定角色和输出形式:只输出 JSON,不要解释。
  2. 给出完整结构:字段名、类型、枚举值都写清楚。
  3. 限制自由发挥:摘要只能概括原文,标签只能从白名单选。
  4. 给不确定情况一个出口:允许 neutral,并说明原因。

常见的错误写法是“帮我分析一下这段评论”。它很好懂,但输出不可预测,没法落盘,也没法统计。把要求写成可检查的约束,比反复调整措辞更有用。

如果模型仍然在 JSON 外面加解释,优先检查两处:

  • 请求里是否带了 format: "json";
  • 系统提示里是否明确禁止 Markdown 代码块。

结果校验与兜底

模型返回 200 不代表结果可用。下面的情况一律标记为需要人工看:

  • 摘要为空,或超过 80 个汉字;
  • 一个合法标签都没选出来;
  • JSON 解析失败。

注意脚本只保留白名单里的标签。模型如果输出了“售后”“物流”这类不存在的标签,会被直接丢掉,而不是悄悄写进结果。这比事后清洗一百条脏数据便宜。

另外,单次请求失败不会中断整批任务:失败的条目会写进 results.failed.jsonl。由于主流程只跳过成功写入结果文件的 id,下次重跑同一条命令时,失败项会自动再试一次。

断点续跑和进度

这个脚本的断点续跑靠两件事:

  • 结果文件按行追加,写完一条就是一条;
  • 启动时读取结果文件,已经成功的 id 不再进入队列。

中途按 Ctrl+C,再执行同一条命令,就会从剩下的条目继续。写入用了一个串行队列,避免两个并发请求同时往文件里追加时把一行 JSON 截断。

查看进度:

wc -l results.jsonl results.failed.jsonl

如果条目很多,建议每处理 50 条打印一次进度,或者在结束后把统计写进一个单独的汇总文件。不要每处理一条就刷新整屏日志,CPU 模型本来就慢,日志刷太快反而看不清。

速度和并发

本地模型的速度主要取决于硬件。可以先拿一条数据测一下:

time node local-llm-batch.mjs one-item.jsonl bench.jsonl

几个实用结论:

  • CPU 跑 7B,速度通常在每秒几个到十几个 token;
  • 并发设 1 到 2 就够,再高只是在抢同一块 CPU 或显存;
  • keep_alive 让模型在处理期间保持加载,避免每条都重新载入;
  • 输入越短越快,但不要为了快把关键上下文截掉;
  • 分类任务用更小的模型通常比用大模型硬扛更划算。

如果一批数据要跑很久,先跑 20 条抽样,确认输出质量,再放开整批。

隐私与安全检查

本地运行不等于自动安全,下面几件事仍然要做:

  • 确认接口只在本机:不要设置 OLLAMA_HOST=0.0.0.0,除非你明确知道自己在做什么。
  • 断网测试一次:关掉网络,完整跑几条,确认没有隐藏的在线调用。
  • 日志也会泄露内容:报错信息和失败记录里可能带原文,分享日志前先脱敏。
  • 模型来源要可信:从官方模型库拉取,不要随便运行来路不明的模型文件。
  • 结果文件放在受控目录:包含原始内容的中间文件,用完该删就删。

什么时候该换回云端

出现这些信号,说明本地小模型已经到了边界:

  • 输出结构长期不稳定,校验失败率居高不下;
  • 需要理解长文档或多轮复杂规则;
  • 数据量突然增大,本地速度成为瓶颈;
  • 任务本身对准确率要求高,需要人工复核所有结果。

这时可以保留本地流程做初筛,只把拿不准的条目交给云端。脚本结构不用大改,换掉 classify 里的请求地址和请求体即可。

本地模型的价值不是“免费替代一切”,而是让大量重复、低风险、涉及内部素材的处理,可以在一台普通电脑上安静地跑完。

下一步读什么

下载模型前先算硬件:本地跑大模型前,先算清这台电脑够不够。

批量任务跑到一半会断,接着看SQLite 断点续跑。

常见问题

7B 模型做摘要和分类够用吗?

对短文本的摘要、打标签、情感判断通常够用,尤其是给了固定标签和输出格式之后。涉及多步推理、长文档理解和复杂业务规则时,小模型会明显吃力,这时再考虑更大的模型或云端服务。

本地跑真的不会上传数据吗?

Ollama 默认只监听本机地址,请求不会离开这台机器。但你要确认脚本里没有同时调用其它在线接口,日志也没有被同步到网盘。最稳妥的验证方式是拔掉网络或关闭 Wi-Fi 后完整跑一遍。

需要独立显卡吗?

不是必须。7B 的量化模型用 CPU 也能跑,只是速度慢一些;内存要留够,模型本身大约 5GB,加上系统和上下文建议至少 8GB 可用内存。有显卡会快很多,但不是入门门槛。

本地模型和云端 API 怎么选?

素材敏感、任务量大、想控制成本时优先本地;需要更强的理解能力、更长的上下文或稳定吞吐时用云端。也可以先用本地模型做初筛,把拿不准的少量条目交给云端。

每周一张复习表

每周分享一张实用复习表,不收钱不打扰,想走随时走

邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。

同方向继续

全部教程