📌 本文部分内容由 AI 辅助整理,已经人工核对。文中脚本是我自己写的,所有输出都是真跑出来的。但有一点必须先说清楚:跑这些输出用的是文中那个假端点,不是真实模型——我手上没有对应的推理环境,所以凡是涉及真实模型速度的地方,我只给方法,不给数字。哪些数字能信、哪些不能,第四节会专门讲。

很多人本地部署是这么结束的:

装驱动、编译、调参数,折腾两个晚上,终于跑起来了。打开对话框问几句,感觉"还行,但不如在线的那个"。然后就没有然后了,模型在硬盘上躺着,再也没打开过。

问题不在模型不够好,在于用法选错了。

把本地模型当成一个更差的聊天窗口,它必输——那不是它的主场。它的主场是另一件事,而那件事恰好是最能省时间的。


一、本地模型的优势不是"更聪明"

拿本地小模型和在线大模型比聪明,方向就错了。参数量差着量级,比不过是正常的。

它真正比得过的是这几条:

调用次数不要钱。 你可以对同一批数据反复跑十遍、换五种 prompt 做对比,成本只有电费。按次计费的场景里,这种"随便试"是不敢想的。

数据不出网。 客户资料、内部文档、代码库——很多东西不是你不想用 AI 处理,是它不能离开这台机器。

没有限流,没有排队,不看别人脸色。 半夜三点它也是那个速度。

能嵌进流程里。 它就是本机的一个 HTTP 接口,可以塞进脚本、塞进定时任务、塞进 CI,不需要联网、不需要管密钥。

这四条凑在一起指向同一类场景:量大、重复、单条价值不高、但加起来很费时间的活。


二、真正划算的三类活

第一类:批量的格式化处理。

把两百份会议记录各自压成三句话;把一堆杂乱的产品描述统一成同一种句式;把日志里的报错按类型归档;给几千条数据打标签。

这类活的共同点是:每一条都不难,难在条数多。 人做一条要一分钟,做两百条就是一下午,而且做到后面质量还会掉。

第二类:不能出网的数据。

合同、病历、简历、内部代码、客户名单。这类数据的处理需求是真实存在的,卡点从来不是技术,是合规。本地模型在这里不是"更便宜的选择"——是根本没有别的选项。

第三类:需要嵌进自动化流程的环节。

监控日志里出现异常,自动让模型读一遍并归类;每天早上把昨天的更新自动整理成一份摘要;提交代码时自动生成一版说明草稿。

这类活的特点是触发频繁、单次输出不长、对延迟不敏感——正好是本地小模型能胜任的区间。


反过来,有两类活别指望它:

  • 需要广博世界知识的。小模型的知识密度就那样,硬问会开始编。
  • 一次性的复杂推理。你就问一次,那直接用在线大模型,省事。

判断标准很简单:这件事你要做一次,还是要做一千次? 一次的用在线,一千次的用本地。


三、效率账该怎么算

这笔账其实很好算,只是很多人没算过。

假设一条任务的输出是 200 个 token,你的机器上模型跑 20 token/s,那么一条要 10 秒。

  • 1000 条串着跑:接近 3 小时
  • 人工做同样的事,一条一分钟:16 小时以上

3 小时对 16 小时,这个差距已经足够说明问题了。但 3 小时依然很长,而这 3 小时里机器大部分时间是在等——上一篇讲过,单请求解码是被内存带宽卡住的,计算单元一直在等数据搬过来。

并发就是把这段等待利用起来:权重读一遍,可以同时喂给多个请求。

所以这笔账的第二步是:并发之后能压到多少。


四、并发能提多少:先说哪些数字能信

先把话说在前面。下面这组数字,是我用文中那个假端点跑的——它每条固定睡 0.3 秒,不做任何真实推理。

并发数24 条总用时吞吐
17.3s3.30 条/秒
42.0s12.26 条/秒
81.2s19.92 条/秒
161.5s16.13 条/秒

这组数字能说明的:并发在调度层面是真的有效,而且存在一个拐点——到 8 是一路涨的,到 16 反而掉回去了。

这组数字不能说明的:你换成真实模型能提速几倍,以及你的拐点在第几档。真实模型的拐点由显存和算力决定:并发上去之后 KV cache 会成倍占显存,瓶颈也会从内存带宽转向算力。位置和这里完全不是一回事。

所以正确的用法不是抄我这张表,是用同一个脚本在你自己的机器上跑一遍,把并发数从 1、2、4、8 一档档往上试,看吞吐在哪一档不再涨。那个数才是你的。

顺便说一句,我在测的时候还撞到过 RuntimeError: can't start new thread——并发开太大,直接撞上系统的线程限制了。并发数不是越大越好,这件事有两个上限:一个是吞吐拐点,一个是系统扛不扛得住。


五、断点续跑为什么是刚需

批量任务跑得久,跑到一半出事是常态:网断了、服务重启了、手贱按了 Ctrl-C、或者干脆断电。

如果结果只在内存里攒着、最后一次性写出去,那这一趟就全白跑了。跑了 800 条挂掉,重来一遍还是从第 1 条开始。

所以规矩只有两条:

  1. 每完成一条立刻落盘并 flush。 挂掉最多只丢正在跑的那几条。
  2. 启动时先读结果文件,把已完成的 id 跳过。 重跑就是续跑,不需要额外的参数,也不需要记住上次跑到哪。

这样"失败"就不再是灾难——重新执行同一条命令就行,它自己知道从哪继续。


六、脚本

零依赖,只用标准库。对接任何 OpenAI 兼容接口。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
batch_run.py —— 把一批重复的活并发丢给本地模型,断点续跑。

本地模型真正省时间的地方不是"比在线大模型聪明",而是**同一件事你可以
跑一万次,不计费、不出网、不看别人的限流脸色**。但要吃到这个好处,
得先解决三个很俗的工程问题:

  · 一条一条串着跑太慢 —— 单请求受内存带宽限制,机器大部分时间在等
  · 跑到第 800 条挂了,重跑一遍前 800 条 —— 时间全白花
  · 偶发超时、连接被重置,一条失败整批停

这个脚本就干这三件事:并发、断点续跑、失败重试。对接任何 OpenAI 兼容
接口(llama.cpp 的 server、Ollama、vLLM 都提供),只用标准库。

⚠️ 边界:
  · 它不管模型答得对不对 —— 那是 prompt 和模型选型的事
  · 并发数不是越大越好,超过拐点吞吐反而掉,拐点取决于你的硬件,
    自己用 --concurrency 试几档,别抄别人的数
  · 没有 GPU 也能先跑通全流程:--mock 会起一个假端点

    python3 batch_run.py --mock                       # 终端 A:起假端点
    python3 batch_run.py --input tasks.jsonl \\
        --out out.jsonl --prompt "一句话概括:" -c 4   # 终端 B:跑
"""

import argparse
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed


def load_tasks(path):
    """读 jsonl,每行一个对象,必须有 id 和 text。"""
    tasks = []
    with open(path, encoding="utf-8") as f:
        for n, line in enumerate(f, 1):
            line = line.strip()
            if not line:
                continue
            try:
                obj = json.loads(line)
            except json.JSONDecodeError as e:
                raise SystemExit(f"{path} 第 {n} 行不是合法 JSON:{e}")
            if "id" not in obj or "text" not in obj:
                raise SystemExit(f"{path} 第 {n} 行缺 id 或 text 字段")
            tasks.append(obj)
    return tasks


def done_ids(out_path):
    """
    已经跑完的 id。断点续跑就靠这个。

    故意逐行读、逐行判:结果文件可能因为上次被 Ctrl-C 掐断而
    最后一行只写了一半,这种残行跳过即可,不该让整批重跑。
    """
    if not os.path.exists(out_path):
        return set()
    ids = set()
    with open(out_path, encoding="utf-8") as f:
        for line in f:
            try:
                ids.add(json.loads(line)["id"])
            except (json.JSONDecodeError, KeyError):
                continue
    return ids


def ensure_trailing_newline(path):
    """
    追加写之前,先保证文件是以换行结尾的。

    上次进程被强杀(断电、kill -9)时,可能正停在写了一半的那行上。
    如果直接追加,新结果会和这条残行粘成一行 —— 粘出来的整行不是合法
    JSON,于是**那条本来已经跑完的记录也一起报废**,下次续跑会白白重跑它。
    """
    if not os.path.exists(path) or os.path.getsize(path) == 0:
        return
    with open(path, "rb+") as f:
        f.seek(-1, os.SEEK_END)
        if f.read(1) != b"\n":
            f.write(b"\n")


def call_once(url, model, prompt, text, timeout):
    """调一次 OpenAI 兼容的 /v1/chat/completions。"""
    body = json.dumps({
        "model": model,
        "messages": [{"role": "user", "content": f"{prompt}\n\n{text}"}],
        "stream": False,
    }).encode("utf-8")
    req = urllib.request.Request(
        url, data=body, headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        data = json.loads(r.read().decode("utf-8"))
    return data["choices"][0]["message"]["content"]


def work(task, args, retries=3):
    """跑一条,失败退避重试。返回的 dict 一定带 ok 字段。"""
    for attempt in range(retries):
        t0 = time.monotonic()
        try:
            out = call_once(args.url, args.model, args.prompt,
                            task["text"], args.timeout)
            return {"id": task["id"], "ok": True, "output": out,
                    "seconds": round(time.monotonic() - t0, 2)}
        except (urllib.error.URLError, TimeoutError, KeyError,
                json.JSONDecodeError) as e:
            if attempt == retries - 1:
                return {"id": task["id"], "ok": False, "error": repr(e)}
            time.sleep(2 ** attempt)          # 1s、2s、4s……


def run(args):
    tasks = load_tasks(args.input)
    skip = done_ids(args.out)
    todo = [t for t in tasks if t["id"] not in skip]

    print(f"  共 {len(tasks)} 条,已完成 {len(skip)} 条,本次要跑 {len(todo)} 条")
    if not todo:
        print("  没有要跑的了。")
        return 0
    print(f"  并发 {args.concurrency},端点 {args.url}\n")

    ensure_trailing_newline(args.out)
    lock = threading.Lock()
    t0, done, failed = time.monotonic(), 0, 0

    # 追加模式:每完成一条立刻落盘并 flush。跑到一半断电也只丢正在跑的那几条。
    with open(args.out, "a", encoding="utf-8") as fout, \
            ThreadPoolExecutor(max_workers=args.concurrency) as pool:
        futures = [pool.submit(work, t, args) for t in todo]
        for fut in as_completed(futures):
            res = fut.result()
            with lock:
                fout.write(json.dumps(res, ensure_ascii=False) + "\n")
                fout.flush()
                done += 1
                failed += 0 if res["ok"] else 1
                el = time.monotonic() - t0
                print(f"\r  {done}/{len(todo)}  失败 {failed}  "
                      f"用时 {el:.1f}s  均速 {done / el:.2f} 条/秒",
                      end="", flush=True)

    el = time.monotonic() - t0
    print(f"\n\n  完成 {done} 条,失败 {failed} 条,总用时 {el:.1f}s")
    if done:
        print(f"  平均每条 {el / done:.2f}s,整体吞吐 {done / el:.2f} 条/秒")
    if failed:
        print(f"  失败的重跑一次即可,已完成的会自动跳过:")
        print(f"    python3 {os.path.basename(__file__)} "
              f"--input {args.input} --out {args.out} ...")
    return 1 if failed else 0


def serve_mock(port):
    """
    一个假的 OpenAI 兼容端点。没有 GPU 也能把整条流程跑通、把并发和
    断点续跑验证清楚,再换成真的端点地址。
    """
    # 必须是 ThreadingHTTPServer。单线程的 HTTPServer 会把并发请求排成队,
    # 拿它测并发只会得出"并发没用"的错误结论——真实的推理服务是并发的。
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

    class H(BaseHTTPRequestHandler):
        def do_POST(self):
            n = int(self.headers.get("Content-Length", 0))
            req = json.loads(self.rfile.read(n).decode("utf-8"))
            time.sleep(0.3)          # 装作在推理
            body = json.dumps({"choices": [{"message": {
                "role": "assistant",
                "content": "[mock] " + req["messages"][0]["content"][:40],
            }}]}).encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def log_message(self, *a):
            pass                     # 别把进度条冲掉

    print(f"  假端点已起:http://127.0.0.1:{port}/v1/chat/completions")
    print("  每条固定 0.3 秒,Ctrl-C 退出")
    ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()


def main():
    p = argparse.ArgumentParser(description="并发批处理 + 断点续跑")
    p.add_argument("--input", help="jsonl,每行 {\"id\":..., \"text\":...}")
    p.add_argument("--out", default="out.jsonl", help="结果 jsonl,追加写")
    p.add_argument("--prompt", default="一句话概括:", help="每条都用的指令")
    p.add_argument("--url", default="http://127.0.0.1:8000/v1/chat/completions")
    p.add_argument("--model", default="local", help="有些端点不校验这个字段")
    p.add_argument("-c", "--concurrency", type=int, default=4)
    p.add_argument("--timeout", type=float, default=120)
    p.add_argument("--mock", action="store_true", help="起假端点,不跑任务")
    p.add_argument("--mock-port", type=int, default=8000)
    a = p.parse_args()

    if a.mock:
        try:
            serve_mock(a.mock_port)
        except KeyboardInterrupt:
            print("\n  假端点已停")
        return 0
    if not a.input:
        p.print_help()
        return 1
    return run(a)


if __name__ == "__main__":
    sys.exit(main())

七、跑给你看

没有 GPU 也能先把整条流程跑通——脚本内置了一个假端点。

开一个终端起假端点:

python3 batch_run.py --mock --mock-port 8323

另一个终端跑任务(tasks.jsonl 每行一个 {"id":..., "text":...}):

python3 batch_run.py --input tasks.jsonl --out out.jsonl \
    --url http://127.0.0.1:8323/v1/chat/completions -c 4

断点续跑演示。 先跑 1.2 秒,然后直接 kill 掉,不给它任何收尾机会:

① 跑到一半 kill(模拟断电)
   → 已落盘 12 条
② 人为追加一条被掐断的残行
③ 续跑
  共 24 条,已完成 12 条,本次要跑 12 条
  并发 4,端点 http://127.0.0.1:8323/v1/chat/completions
  完成 12 条,失败 0 条,总用时 1.1s
  平均每条 0.09s,整体吞吐 11.17 条/秒
④ 全部跑完后再跑一次
  共 24 条,已完成 24 条,本次要跑 0 条
  没有要跑的了。
合法 24 条 / 去重 24 个 / 残行 1 条 / 零重复跑: True

第 ② 步是故意的:往结果文件尾巴上塞了半截不完整的 JSON,模拟"上次正写到一半就断电了"。续跑照常识别出 12 条已完成,没有把残行当成灾难,也没有重复跑任何一条。

换成你真实的端点,只要改 --url 就行。


八、批量场景的 prompt 和聊天不一样

这一节其实比脚本重要。脚本只解决"跑得动",prompt 决定"跑出来的东西能不能用"。

聊天和批量的根本差别是:有没有人在看。

聊天时模型多说两句废话无所谓,你自己会挑;格式偶尔飘一下也无所谓,你会追问一句。批量跑一千条,没有人一条条看——只要格式飘了,后面的解析就全废,而你要到最后才发现。

所以批量场景的 prompt 有三条硬要求:

① 把"只输出什么"说死,并且明确禁止解释。 不要写"请概括一下",要写"直接输出一句话概括,不要任何前言、解释或补充说明"。模型很爱在正文前面加一句"好的,以下是概括:",这一句会毁掉你的下游解析。

② 给例子,比给形容词管用。 "请简洁"这种要求每个模型的理解都不一样。直接给一个输入输出的样例,它照着学,稳定得多。小模型尤其吃这套。

③ 限死长度。 "不超过 30 字"比"简短一些"有效得多,而且顺带控制了耗时——输出长度直接决定单条要跑多久。

如果下游要机器解析,就让它输出 JSON,并且自己加一层校验。 模型偶尔会吐出带 ```json 包裹的、或者末尾多个逗号的东西。解析失败的那些单独挑出来重跑一遍,比事后人工修便宜得多。

温度调低。 批量任务要的是稳定不是创意,大部分端点都支持传 temperature,往低了调。

最后一条,也是最容易省掉又最不该省的:先拿十条跑。

跑这十条不是看内容写得好不好,是看三件机械的事:格式是不是每条都一样、有没有多余的开场白、长度有没有失控。这十条稳了再放开跑一千条。否则你花三个小时批量生产的,是一千份格式各异的垃圾——而且电费和时间都是真花掉了。


九、写这个脚本时踩的两个坑

坑一:假端点是单线程的,测并发全是假的。

http.server 里的 HTTPServer 一次只处理一个请求。用它测并发,请求会乖乖排队,你会看到并发 8 和并发 1 一样快,然后得出"并发没用"的结论。

改成 ThreadingHTTPServer 才对。真实的推理服务是并发处理的,测试用的假端点必须在这一点上保持一致,否则它验证的东西是错的。

坑二:残行会把一条已完成的记录一起毁掉。

这个更隐蔽。进程被强杀时可能正停在写了一半的那行上,文件末尾没有换行。下次续跑直接追加,新结果就和这条残行粘成了一行:

{"id": "doc-099", "ok": tr{"id": "doc-013", "ok": true, ...}

粘出来的整行不是合法 JSON,于是解析时被跳过——那条本来已经跑完的记录也一起报废了,下次续跑会白白重跑它。

我是怎么发现的:全部跑完之后又跑了一次,本该输出"没有要跑的了",结果它跑了 1 条。一条,正好一条。

修法是追加前先补一个换行:

with open(path, "rb+") as f:
    f.seek(-1, os.SEEK_END)
    if f.read(1) != b"\n":
        f.write(b"\n")

这两个坑的共同点是:不看输出细节根本发现不了。 第一个会让你得出错误结论,第二个会安静地多花一次调用。批量任务跑上千条的时候,"安静地多花一点"累积起来就不是小数目。


十、它不解决什么

它不管模型答得对不对。 输出质量是 prompt 和模型选型的事,脚本只负责把活派出去、把结果收回来。批量跑之前一定先用十条试,确认输出格式稳定了再放开跑一千条,否则就是批量生产垃圾。

它没做限流。 并发数是你自己给的,给多大它就开多大。对着别人的付费 API 用这个脚本要小心,本地服务无所谓。

它没做结果校验。 返回什么就存什么。如果你要求模型输出 JSON,那还得自己加一层解析和重试。

失败的那些条它不会自动重跑。 单条内部会退避重试三次,三次都失败就记 ok: false 落盘。你重新执行同一条命令,它会把失败的那些再试一遍——因为它们不在"已完成"里。


小结

  • 本地模型的价值不在"更聪明",在不计次、不出网、能嵌进流程
  • 判断标准:这件事你要做一次,还是一千次?一次的用在线,一千次的用本地
  • 单请求慢是因为机器一直在等数据,并发就是把这段等待利用起来
  • 并发有拐点,别抄别人的数,用同一个脚本在自己机器上一档档试
  • 批量任务的两条铁规矩:逐条落盘 + 启动时跳过已完成
  • 跑一千条之前先跑十条

最后留个问题。第四节那张表是假端点跑的,真实模型的拐点位置我没有环境验证。如果你在自己的机器上试过并发梯度,吞吐是在第几档不再涨的,你的显存和模型规模是多少——这个组合我很想知道。 这种数据没人整理过,多几个人报一下,大家选并发数就不用瞎试了。

Logo

聚焦RDK与具身智能,汇集机器人开发教程、开源案例和技术交流

更多推荐