一上来就在本地给书生-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 的显存统计。判断是否继续加量的规则可以这样定:
- 峰值显存接近可用上限,不再加量;
- 单条平均耗时相比上一档出现明显上升,先停在这一档排查;
- 出现 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 覆盖、模型路径是否指向同一份权重。之后再想提高并发,改动也只发生在脚本里的那两个数值上,出了问题直接退回上一个提交过的版本即可。