断网也能批量处理:用本地模型做摘要、分类和打标签
用 Ollama 在本机跑一个 7B 级模型,配合一段 Node 脚本批量处理访谈、评论和文章,结果校验后落盘,素材不出本机。
本页目录
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
提示词怎么写才稳定
这段脚本里,提示词做了四件事:
- 限定角色和输出形式:只输出 JSON,不要解释。
- 给出完整结构:字段名、类型、枚举值都写清楚。
- 限制自由发挥:摘要只能概括原文,标签只能从白名单选。
- 给不确定情况一个出口:允许
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说「退订」就删掉。详见隐私政策。