# 我把 Qwen2.5-Coder-0.5B 跑成了代码审查 API:海光 BW、vLLM 与 8 并发实测
https://n-2082711171478953986.zzai.scnet.cn:58043/jupyter-forward/2082711171478953986/lab/tree/root

第一篇里,我用 Transformers 验证了 Qwen 权重能在海光 BW 上离线推理。第二次登录服务器时,我不想再写一个“输入问题、终端打印答案”的脚本了。真正接应用时,调用方需要的是 HTTP 接口,而不是某个 Python 进程里的 `generate()`。
我因此把 Qwen2.5-Coder-0.5B-Instruct 交给 vLLM,目标也定得很具体:
```text
模型能加载
-> 服务能监听端口
-> /v1/models 能返回
-> /v1/chat/completions 能完成请求
-> 代码答案能被人工和测试复核
-> 1/2/4/8 并发有可重复数据
```
过程并没有一遍过。第一次后台启动后端口始终没有起来,问题最后落在 Bash 的 `set -u`;服务跑通以后,0.5B Coder 又给了几段“解释正确、修复代码却原样不动”的答案。这两件事比一个漂亮的成功截图更值得写下来。
补测后的结果是:并发从 1 增到 8,聚合生成速度从 34.71 提升到 190.33 token/s;并发 4 时 P95 延迟为 1.5690 秒,并发 8 时升到 2.7509 秒。对这组短请求而言,4 并发附近是一个更舒服的平衡点——但这只是当前模型、当前提示词下的观察,不是设备的容量上限。
> 文中的日志、延迟和吞吐都来自这台实例。vLLM 采用 `--enforce-eager` 建立兼容性基线,未开启编译和 CUDAGraph,因此数据不能代表优化后的最高性能。
## 我为什么选 0.5B Coder,而不是一开始就追求大模型
这次使用的模型是:
ModelScope 页面标注约 494.03M 参数、快照约 999.60MB,许可证为 Apache-2.0。我在可联网电脑完成下载,再把 11 个文件传到海光节点。远端目录约 954MiB,核心权重哈希为:
```text
f9523886352217ded3aeeef552b381af79d568c6d49a4b9e423288cea56b0a44
```
选择这个尺寸不是因为它足以做生产级代码审查,而是因为我想先固定服务框架。只要 OpenAI 兼容协议、日志、客户端和并发脚本不变,后面替换更大的 Coder 模型时,业务侧不需要跟着重写。
当前环境:
| 项目 | 实测值 |
| --- | --- |
| 设备 | Hygon BW |
| 设备显存 | 63.98GiB |
| Python | 3.10.12 |
| PyTorch | 2.10.0 |
| HIP | 6.3.26093 |
| vLLM | 0.18.1(海光环境构建) |
| 权重精度 | BF16 |
| 最大上下文 | 4096 token |
| 服务监听 | `127.0.0.1:8008` |
我只让服务监听回环地址。这个实验接口没有鉴权,直接暴露到公网是不负责任的。如果以后真要让其他机器访问,我会在它前面增加反向代理、鉴权、限流和 TLS。
## 第一次启动为什么没成功
我最初写的脚本很“标准”:
```bash
#!/usr/bin/env bash
set -euo pipefail
source /opt/dtk/env.sh
vllm serve ...
```
后台执行以后,日志没有进入权重加载,`curl` 也一直连接失败。尾部只有一句:
```text
/opt/dtk/env.sh: line 26: CMAKE_PREFIX_PATH: unbound variable
```
问题和模型、显存都无关。`set -u` 会在读取未定义变量时立即终止,而平台环境脚本初始化过程中需要先处理 `CMAKE_PREFIX_PATH`。我把顺序改成先加载环境,再开启 `nounset`:
```bash
#!/usr/bin/env bash
set -eo pipefail
MODEL_DIR="${1:-/root/qwen-modelscope-labs/models/Qwen2.5-Coder-0.5B-Instruct}"
PORT="${PORT:-8008}"
LOG_DIR="${LOG_DIR:-/root/qwen-modelscope-labs/logs}"
mkdir -p "${LOG_DIR}"
source /opt/dtk/env.sh
set -u
exec vllm serve "${MODEL_DIR}" \
--served-model-name qwen25-coder-0.5b \
--dtype bfloat16 \
--max-model-len 4096 \
--gpu-memory-utilization 0.35 \
--host 127.0.0.1 \
--port "${PORT}" \
--enforce-eager
```
这个修改看起来只挪了两行,实际很关键。很多部署问题并不是“国产加速卡不兼容”,而是启动脚本沿用了另一套环境假设。先把日志读完整,比反复换模型参数有效得多。
## 我如何确认服务不是“假启动”
第二次启动后,进程很快出现,但端口仍要等一段时间。我没有看到 PID 就宣布成功,而是等四个信号都满足:
1. 日志出现 `Platform plugin hcu is activated`;
2. 架构解析为 `Qwen2ForCausalLM`;
3. 日志出现 `Application startup complete`;
4. `/v1/models` 实际返回 HTTP 200。
这一轮日志还记录了:
```text
Loading weights took 0.20 seconds
Available KV cache memory: 20.46 GiB
GPU KV cache size: 1,788,224 tokens
```

接口检查:
```bash
curl -s \
http://127.0.0.1:8008/v1/models \
| python -m json.tool
```
返回的模型 ID 是 `qwen25-coder-0.5b`,最大上下文为 4096。到这里,我才把服务状态记为可用。
日志里还给出了“Maximum concurrency for 4,096 tokens per request: 436.58x”。这个数字来自 KV Cache 理论容量,不能直接当成业务并发。真实请求还会受到提示词长度、输出长度、排队和延迟目标影响,所以我后面自己跑了并发矩阵。
## 节点不能装 SDK,我就用标准库写客户端
远端外网不可达,为了调用一个本地 HTTP 接口再安装 OpenAI SDK 没必要。我用 `urllib.request` 写了一个最小客户端:
```python
import json
import time
import urllib.request
def chat(prompt: str, max_tokens: int = 256):
payload = {
"model": "qwen25-coder-0.5b",
"messages": [
{
"role": "system",
"content": (
"你是严谨的 Python 代码审查员。"
"指出可复现的问题并给出可运行修改。"
),
},
{"role": "user", "content": prompt},
],
"temperature": 0,
"max_tokens": max_tokens,
}
request = urllib.request.Request(
"http://127.0.0.1:8008/v1/chat/completions",
data=json.dumps(
payload,
ensure_ascii=False,
).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
started = time.perf_counter()
with urllib.request.urlopen(
request,
timeout=180,
) as response:
body = json.loads(
response.read().decode("utf-8")
)
return {
"latency": time.perf_counter() - started,
"usage": body.get("usage"),
"answer": body["choices"][0]["message"]["content"],
}
```
标准库客户端有两个好处:部署包更小,而且请求体、超时和返回 token 数都看得很清楚。等接口行为确认后,业务项目再换官方 SDK 也不迟。
## 三个代码任务,模型到底答得怎么样
我没有用“写一个 Hello World”来证明 Coder 能工作,而是故意给了三个容易产生伪正确答案的任务。
### 去重排序:找到了复杂度问题,却没有真正改代码
输入函数:
```python
def unique_sorted(values):
result = []
for value in values:
if value not in result:
result.append(value)
return sorted(result)
```
模型指出 `value not in result` 会反复做线性扫描,最坏复杂度达到 $O(n^2)$。判断是对的,但它给出的“修改版”仍然是原代码,随后还重复了几段复杂度说明。
如果元素可哈希,我会改成:
```python
def unique_sorted(values):
return sorted(set(values))
```
这让我确认了一件事:自然语言里的“已修复”不能作数,必须比较补丁是否真的改变了程序。
### pytest:有测试的样子,但函数签名对不上
模型生成了 `@pytest.mark.parametrize`,却没有让测试函数接收 `values` 和 `expected`,也没有真正验证原列表未被修改。代码块看起来完整,直接执行会失败。
我最后采用的是:
```python
import pytest
@pytest.mark.parametrize(
("values", "expected"),
[
([3, 1, 3, 2], [1, 2, 3]),
([], []),
([-1, -3, -1, 2], [-3, -1, 2]),
],
)
def test_unique_sorted(values, expected):
original = list(values)
assert unique_sorted(values) == expected
assert values == original
```
### 原子写入:解释提到了原子性,代码仍是普通覆盖
第三个任务要求修复并发写 JSON。模型分析了覆盖风险,最终给出的函数却仍然是 `open(path, "w")` 加 `json.dump()`。它说自己使用了原子写入,代码并没有。
同一文件系统内,我会先写临时文件,再调用 `os.replace`:
```python
import json
import os
import tempfile
from pathlib import Path
def save_json(path, data):
target = Path(path)
target.parent.mkdir(
parents=True,
exist_ok=True,
)
fd, temp_name = tempfile.mkstemp(
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
)
try:
with os.fdopen(
fd,
"w",
encoding="utf-8",
) as handle:
json.dump(
data,
handle,
ensure_ascii=False,
)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, target)
except Exception:
try:
os.unlink(temp_name)
except FileNotFoundError:
pass
raise
```
这仍然只能保证读者不会看到半个文件。如果多个写入者存在业务冲突,还需要锁、版本号或数据库事务。
三个任务跑下来,我对 0.5B Coder 的定位很明确:它适合验证接口、生成草稿、辅助教学,不适合无人复核地修改仓库。模型答案必须继续经过格式化、静态检查、单元测试和沙箱执行。
## 第一组 4 并发,只能算冒烟
最初我只做了四个并发短请求。墙钟时间 5.0809 秒,共生成 384 token,聚合速度 75.58 token/s。

这组数据能证明 vLLM 在调度并发请求,但样本太少,而且和三条较长代码任务混在同一轮里。我后来重新写了一个并发矩阵脚本,每档都先预热,再重复三轮。
## 补测 1/2/4/8 并发:8 并发吞吐更高,尾延迟开始抬头
矩阵测试使用同一类短提示词,单条最多输出 96 token。每个并发档位重复三轮,所有请求都返回 HTTP 200。
| 并发 | 总请求数 | 聚合吞吐 | 请求吞吐 | P50 延迟 | P95 延迟 |
| ---: | ---: | ---: | ---: | ---: | ---: |
| 1 | 3 | 34.71 token/s | 0.643 req/s | 1.5531s | 1.5594s |
| 2 | 6 | 67.62 token/s | 1.284 req/s | 1.5298s | 1.6074s |
| 4 | 12 | 134.61 token/s | 2.556 req/s | 1.5374s | 1.5690s |
| 8 | 24 | 190.33 token/s | 2.910 req/s | 1.5991s | 2.7509s |

从 1 到 4 并发,聚合吞吐接近线性增长,P95 仍在 1.6 秒左右。到 8 并发时,吞吐继续增加,但 P95 升到 2.7509 秒,请求吞吐的增幅也明显变小。
我不会仅凭四个档位就宣布“最佳并发是 4”,但对当前这类短请求,4 是更平衡的起点。如果目标是离线批量处理,8 并发的吞吐更有吸引力;如果目标是交互式代码助手,我会先守住尾延迟,再逐步加压。
这轮还没有测首 token 延迟,也没有持续半小时以上。正式容量测试还要补输入/输出长度分桶、错误率、P99、服务端队列和持续显存曲线。
## `hy-smi` 的一张 0% 利用率截图,不能推翻请求日志
vLLM 按 `--gpu-memory-utilization 0.35` 预留显存后,`hy-smi` 显示 VRAM 35%。我截取的某个瞬间,HCU 利用率恰好是 0%。

这种瞬时值很容易被误读。请求日志已经记录了连续的 HTTP 200、生成 token 和吞吐,客户端也拿到了完整响应。单张快照只能说明采样那一刻的状态,不能代表整个请求窗口的平均利用率。
如果要做宣传或正式评测,我会按固定间隔连续采样,并把利用率曲线和请求时间线对齐。挑一张最高值或最低值都不够严谨。
## 这次部署里海光价值
- 标准 Safetensors 权重能够被识别;
- Qwen2 架构、BF16 和 KV Cache 正常工作;
- vLLM 自动激活 `hcu` 平台插件;
- OpenAI 兼容接口可以沿用现有客户端;
- 1、2、4、8 并发都有稳定的 HTTP 响应;
- 64GB 级显存给更大模型和更长上下文留下了空间。
对个人开发者来说,这种迁移平滑度比口号有用。我可以保留原来的模型目录、API 协议和测试代码,把精力放在启动环境、参数和验收上,而不是从头重做应用。
实验结束后,我确认了 vLLM 主进程号,再发送终止信号,`hy-smi` 中的 VRAM 占用恢复到 0%。我不习惯把测试服务一直挂在机器上,更不会用模糊的批量终止命令,以免影响同机上的其他进程。
下一次我准备直接替换更大的 Qwen Coder,保持这套接口和并发脚本不变。到那时,最值得比较的不会只有 token/s,还包括代码修复通过率、单测成功率、首 token 延迟和单位显存能承载的并发。