先跑通单条推理再谈并发——书生-S2 本地调用的最小闭环

文章导读
一上来就在本地给书生-S2 加并发,出错往往不是并发参数本身写错了,而是单条调用还没有被固定成一个可重复的基准。请求能返回一次文本,不等于链路已经跑对:模型路径可能指向了另一份权重,服务可能加载了默认参数,输出可能因为采样设置每次都不一样。先把一条输入跑出稳定结果,再把输入输出写进文件,然后从两条开始加量,最后才动并发相关的配置,这样任何一次变慢或报错都能回溯到具体是哪一步改动的。
📋 目录
  1. A 搭出只跑一条输入的最小调用骨架
  2. B 把输入与输出落盘确认可复现
  3. C 批量从两条开始逐级增加
  4. D 并发出错时抓取日志并回退
  5. E 把验证过的配置固化成启动脚本
A A

一上来就在本地给书生-S2 加并发,出错往往不是并发参数本身写错了,而是单条调用还没有被固定成一个可重复的基准。请求能返回一次文本,不等于链路已经跑对:模型路径可能指向了另一份权重,服务可能加载了默认参数,输出可能因为采样设置每次都不一样。先把一条输入跑出稳定结果,再把输入输出写进文件,然后从两条开始加量,最后才动并发相关的配置,这样任何一次变慢或报错都能回溯到具体是哪一步改动的。

单条推理先跑通并落盘,是本地调用里成本最低的一次校准。做法是固定模型路径、推理参数和一条固定输入,确认返回文本、finish_reason、token 用量和耗时都能稳定复现,再以 JSONL 形式留存;批量从两条起逐级增加,每档记录耗时、显存与错误类型,一旦出现 OOM 或单条平均耗时明显上升就停在上一个档位。并发上限取决于你的显存和模型量化方式,只能由本地日志和显存读数给出,不能照搬别人的数值。

搭出只跑一条输入的最小调用骨架

这一步的目标很窄:让一次请求返回完整结果,并且你能看到除文本之外的几个字段。如果本地已经把书生-S2 起成了 HTTP 服务,用下面的请求骨架;如果是直接用 Python 加载权重,把请求部分换成模型加载与 generate 调用,其余观察点不变。所有路径、模型名和端口都是占位值,按你本地的实际部署替换。

# single_call.py —— 只跑一条输入,先确认链路通
import json, time, requests

MODEL_PATH = "/path/to/your/intern-s2"      # 替换为本地模型目录
BASE_URL   = "http://127.0.0.1:8000/v1"      # 替换为本地服务地址
MODEL_NAME = "your-served-model-name"       # 替换为服务端注册的模型名

PROMPT = "用一句话说明什么是定长数组。"      # 固定输入,不要每次改
PARAMS = {"temperature": 0, "max_tokens": 128, "top_p": 1.0}

payload = {
    "model": MODEL_NAME,
    "messages": [{"role": "user", "content": PROMPT}],
    **PARAMS,
}

t0 = time.time()
resp = requests.post(f"{BASE_URL}/chat/completions", json=payload, timeout=600)
elapsed = time.time() - t0
data = resp.json()
text = data["choices"][0]["message"]["content"]
print(text)
print({"elapsed": round(elapsed, 2),
       "finish_reason": data["choices"][0].get("finish_reason"),
       "usage": data.get("usage")})

运行后要观察的形态是:一段完整、没有中途截断的回答,finish_reason 为 stop,usage 里有输入和输出的 token 数,以及一次可打印的耗时。如果报连接失败,通常是服务没起来或端口不对;如果报模型不存在,是 MODEL_PATH 或 MODEL_NAME 填错了;如果输出到一半就停,先看 max_tokens 是否偏小,再看显存是否吃紧。这一条跑通之前,不要进入下一步。

把输入与输出落盘确认可复现

固定输入的意义在于,后面所有对照都有了同一个基准。建议每次调用都追加写一行 JSON 到同一个 JSONL 文件,字段包括:run_id、时间戳、prompt 原文、完整参数、输出文本、finish_reason、usage、耗时。用 temperature 为 0 或固定 seed 时,多数情况下两次文本会一致,但也不保证完全逐字相同,差异可能只出现在空白或换行。

比对方法很简单:连跑两次,把易变字段去掉后再 diff。

diff <(jq -S 'del(.run_id,.ts,.elapsed_s)' run1.jsonl) \
     <(jq -S 'del(.run_id,.ts,.elapsed_s)' run2.jsonl)

没有差异说明基准稳了;只有耗时和文本的顺序差异,也可以接受;如果输出内容整体变了,先回去检查参数是否被默认值覆盖,而不是急着怀疑并发。落盘格式建议一行一个 JSON,便于后续用 jq、grep 或脚本切分,不要写成多行缩进的 JSON,否则按行读取会失败。

批量从两条开始逐级增加

批量的递进方式建议是 1 → 2 → 4 → 8,每一档至少跑两轮,同一档两轮都正常,才升到下一档。这里加的是每批的条数,不是同时打向服务的并发请求数,两者要分开记录,不要混成一个参数。

每一档需要落的字段:batch_size、并发数、总耗时、单条平均耗时、峰值显存、错误次数、错误类型、首条与末条的完成时间。显存可以用 nvidia-smi 轮询采样,也可以在 Python 侧记录 torch 的显存统计。判断是否继续加量的规则可以这样定:

先跑通单条推理再谈并发——书生-S2 本地调用的最小闭环
  • 峰值显存接近可用上限,不再加量;
  • 单条平均耗时相比上一档出现明显上升,先停在这一档排查;
  • 出现 OOM、超时或返回不完整,直接退回上一档;
  • 各档都稳定,才把 batch_size 固化成配置项。

显存和耗时的绝对值只能来自你自己的机器和模型量化方式,别人给的数字没有参考价值,记录的目的就是拿到本地那份读数。

并发出错时抓取日志并回退

加到并发之后报错,第一件事不是继续调参,而是把现场留下来。需要保留的日志字段包括:时间戳、批次大小、并发数、完整请求体、异常类型与堆栈、服务进程 PID、当时的显存快照,以及服务的退出码。缺了其中任何一项,后面复现时都只能靠猜。

回退步骤按顺序做:先停止正在跑的服务;把并发数改回 1,batch_size 改回已经验证通过的那一档;确认旧进程确实退出,避免残留进程继续占用显存;然后用落盘的那条固定输入重跑一次,和基线记录做对比。对比通过,说明回到了已验证配置;对比不通过,说明问题不在并发,而在模型加载或环境本身。

需要提醒的是,常见的显存清理、重启服务这类操作只是让现场恢复干净,属于止血手段,不是并发能力的提升,别把它当作可以长期依赖的方案。

把验证过的配置固化成启动脚本

验证到哪一档,就把那一档写进启动脚本,让下次运行默认落在已验证区间,而不是手动敲参数。下面的骨架只是结构示例,启动命令按你本地的实际方式替换。

#!/usr/bin/env bash
set -euo pipefail

export MODEL_PATH="/path/to/your/intern-s2"
export PORT=8000
export BATCH_SIZE=2            # 填已验证通过的档位
export MAX_CONCURRENCY=1       # 先用最保守的值

exec python -m your_server_launcher \
  `--model` "$MODEL_PATH" \
  `--port` "$PORT" \
  `--batch-size` "$BATCH_SIZE" \
  `--max-concurrency` "$MAX_CONCURRENCY"

重启后做一次一致性验证:用同一条固定输入再跑一次,把新结果与基线 JSONL 的关键字段比对。命令可以沿用前面的思路。

jq -S 'del(.run_id,.ts,.elapsed_s)' baseline.jsonl > /tmp/base.json
jq -S 'del(.run_id,.ts,.elapsed_s)' after_restart.jsonl > /tmp/now.json
diff /tmp/base.json /tmp/now.json && echo "配置一致"

一致就说明脚本里的参数确实生效;不一致就逐项检查环境变量是否被 shell 覆盖、模型路径是否指向同一份权重。之后再想提高并发,改动也只发生在脚本里的那两个数值上,出了问题直接退回上一个提交过的版本即可。