接入 vLLM 的 qwen3.8 模型:WorkBuddy 自定义模型配置教程、踩坑记录与心路历程
适用场景:把自建 / 第三方 OpenAI 兼容推理服务(vLLM、Ollama、LM Studio、云端推理 API 等)接入 WorkBuddy 作为「自定义模型」使用。
本文所有服务器地址、端口、密钥、容器名、路径均为占位符,请按实际环境替换;不涉密。
目录
- 零、接入背景与模型说明
- 一、我的心路历程:从一个 400 到三层根因
- 二、WorkBuddy 自定义模型配置教程
- 三、踩坑记录(坑 1 ~ 坑 5)
- 四、关键结论与速查表
- 五、一句话总结
零、接入背景与模型说明
1. 我们接入了什么
本次把一套自建推理服务接入了 WorkBuddy:
- 模型 :
qwen3.8-27b-FP8(Qwen3 系列,约 27B 参数规模),以 FP8 量化部署,兼顾显存占用与推理质量。 - 推理框架 :vLLM,对外暴露 OpenAI 兼容接口(
/v1/chat/completions、/v1/models)。 - 部署形态 :多卡张量并行(
tensor-parallel-size=2)、启用 MTP 投机解码 加速、上下文窗口开到很大(支持超长输入)、开启 reasoning 推理解析 (thinking)与 auto tool choice 自动工具调用。 - 接入目的:让 WorkBuddy 的 Agent 直接调用这个模型做深度思考(reasoning)、普通对话与工具调用。
2. 为什么值得记一笔
Qwen3 这类「会思考」的新一代模型,和老一代纯对话模型在接入时差异很大:
- 它会在请求里带
reasoning_effort这类推理强度参数,而不同模型的 chat template 对参数取值的宽容度不同; - 它支持工具调用(function calling),而工具调用的流式输出格式正是很多客户端解析的暗礁;
- WorkBuddy 又是默认走流式、且 UI 上没有开关关掉流式的客户端。
这三件事叠加在一起,让接入过程一路踩坑。下面既是教程,也是一份「踩坑复盘」。
一、我的心路历程:从一个 400 到三层根因
这部分是我(排查人)当时的思考路径,写出来是想说明:同一个报错,可能藏着完全不同的根因,千万别被第一眼现象骗了。
第一眼:以为是「流式续传 body 空了」
最开始看到报错:
400 ... JSON decode error ... Expecting value ... input: {}
而且现象是「先正常吐出一段,然后报错」。我第一反应是:流式输出中途断了,客户端续传时发了个空 body,服务端解析失败。这是最常见的流式 bug 猜想,于是我先去查 vLLM 日志、版本、有没有 streaming + tool-call 的已知问题。
第二眼:发现是「服务端模板在渲染阶段就拒了」
查 vLLM 日志时,真正的报错露出来了:
jinja2.exceptions.TemplateError: Unexpected reasoning effort high.
Supported types are xhigh (default), medium, and low.
原来不是流式续传的问题 ------ 是 WorkBuddy 默认给 Qwen3 发了 reasoning_effort: "high",但 Qwen3 的 chat template 只认 xhigh / medium / low (注意是 xhigh,不是 high)。模板里直接 raise_exception,vLLM 在 apply_chat_template 阶段就 400 了。
踩的第一个深坑 :我本能地去改 tokenizer_config.json 里的 chat_template 字段,怎么改都不生效。最后才发现,这台 vLLM 真正读取的是模型目录下的独立文件 chat_template.jinja,而不是 tokenizer_config 里的字段。改错文件 = 白忙活。
还踩了一个 Jinja2 作用域坑 :第一次补丁我写成在 {% if %} 块里 {% set %},结果这是块级局部变量,改不到外层变量,模板仍拒绝。必须改成顶层 set + 条件表达式重赋值。
修好这一层后,普通对话确实通了。我一度以为结束了。
第三眼:工具调用一触发,400 又回来了
普通对话 OK,但只要模型一触发工具调用(请求里带 tools),400 又来了,报错长得一模一样。这次我学乖了------先直接抓流式响应看 vLLM 到底吐了什么:
text
chunk 1: tool_calls[0].function.arguments = ""
chunk 2: tool_calls[0].function.arguments = '{"a": '
chunk 3: tool_calls[0].function.arguments = '23, "b": '
chunk 4: tool_calls[0].function.arguments = '47}'
真相大白:vLLM 把工具调用的 arguments 拆成多片 SSE 流出来了 。WorkBuddy 网关每收到一片,就立刻拿 arguments 去做 json.loads() ------ 空串和半截 JSON 当然解析失败 → Expecting value → 400。这回是客户端网关解析 SSE 分片崩了,不是服务端拒绝。
验证也很干净:同一请求加 "stream":false,vLLM 一次性返回完整 JSON("arguments":"{\"a\": 23, \"b\": 47}"),完美解析。所以问题只在「流式 + 工具调用」组合。
第四眼:想关流式,发现关不掉
最直接的想法是「那就不走流式呗」。结果翻遍 WorkBuddy 自定义模型配置,根本没有「流式输出 / stream」这个开关 ------ 流式是客户端内置默认行为,UI 上改不了。这条路死了。
第五眼:加一层极薄代理,既保工具又适配客户端
既然客户端改不了、又必须流式,那就在服务端前面加一层转发代理:
- 带
tools的请求 → 代理强制stream=false调 vLLM → 拿到完整结果 → 包成一个 SSE chunk +[DONE]回传给 WorkBuddy; - 不带
tools的普通对话 → 代理真流式透传。
WorkBuddy 以为自己一直在收流式,工具调用也能拿到完整 JSON,400 消失,工具能力完好保留。
第六眼:改完 Base URL 报 502,防火墙忘了开
代理在服务器本地跑得好好的,把 WorkBuddy 的 Base URL 指向代理端口后,却报 502 Server error。本地 curl localhost:8900 一切正常,外部连不上 ------ 一查是 UFW 没放行代理端口。放行后立刻恢复。
三层根因,同一种报错表象:
- 服务端模板不认
reasoning_effort: "high"→ 服务端 400 - 流式工具调用
arguments分片 → 客户端网关 400 - 防火墙没开代理端口 → 网络 502
二、WorkBuddy 自定义模型配置教程
1. 入口
WorkBuddy 设置 → 模型 / 自定义模型 → 新建自定义模型 → 选择「OpenAI 兼容」类型。
2. 必填字段
| 字段 | 说明 | 示例(占位) |
|---|---|---|
| 模型名称 / Model ID | 在 WorkBuddy 里显示的模型名 | qwen3-local |
| API Base URL | 推理服务的 OpenAI 兼容地址,必须指向 /v1 |
http://<SERVER_IP>:<PORT>/v1 |
| API Key | 服务鉴权密钥;无鉴权时可填任意非空串或留空(取决于服务端) | sk-xxxx |
| 额外请求头(可选) | 部分网关需要自定义 header | Authorization 等 |
高级配置里的「工具调用」「图片输入」「推理模式」「思考强度」等开关,按需勾选即可;它们本质是让客户端在请求里加
tools、reasoning_effort等参数。
3. 关键约定
- Base URL 必须带
/v1后缀 。WorkBuddy 会在后面拼/chat/completions、/models等路径。 - 模型名要对得上 :填的 Model ID 必须存在于服务端的
/v1/models返回里,否则会报model_not_found。 - 流式是默认行为 :WorkBuddy 调用自定义模型时默认走 SSE 流式(stream=true),UI 上没有关闭流式的开关。
- 「推理模式 / 思考强度」选项,本质是客户端下发
reasoning_effort等参数,具体取值是否被模型接受,取决于模型 chat template(见坑 1)。
4. 连通性自检(在能访问服务端的机器上)
bash
# 1) 鉴权 + 模型列表
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer <API_KEY>" \
http://<SERVER_IP>:<PORT>/v1/models
# 2) 最小对话(非流式)
curl -s -H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{"model":"<MODEL_ID>","messages":[{"role":"user","content":"hi"}],"max_tokens":32,"stream":false}' \
http://<SERVER_IP>:<PORT>/v1/chat/completions
两个都返回 200 即代表服务层正常,问题才可能在 WorkBuddy 这一侧。
三、踩坑记录
坑 1:客户端发送 reasoning_effort: "high" 导致服务端 400
现象
对话先正常吐出一段,随后报错:
400 ... validation error ... JSON decode error ... Expecting value
表面像是「流式续传 body 为空」,实际是服务端在渲染模板时就拒了。
根因
部分推理客户端(含 WorkBuddy 的某些模型预设)会默认下发 reasoning_effort: "high"。而 qwen3.8-27b-FP8 等 Qwen3 系列 chat template 只接受 xhigh / medium / low (注意是 xhigh 不是 high),模板里直接 raise_exception("Unexpected reasoning effort high"),于是 vLLM 在 apply_chat_template 阶段返回 400。
定位技巧
- 直接对
/v1/chat/completions发带"reasoning_effort":"high"的请求,能稳定复现即坐实。 - 检查模型目录下的模板文件:真正生效的往往是独立的
chat_template.jinja,不是tokenizer_config.json里的chat_template字段(vLLM 优先读独立.jinja文件)。改错文件只会白忙。 - Jinja2 的
{% set %}在{% if %}块内是块级作用域,补丁如果用块级set重赋值会失效,必须用顶层set+ 条件表达式。
服务端修复(保留思考能力、不改客户端)
在模板顶部,把 high 映射成 xhigh:
jinja
{# 原来 #}
{%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %}
{# 改成 #}
{%- set _re = reasoning_effort|default('xhigh') %}
{%- set resolved_reasoning_effort = 'xhigh' if _re == 'high' else _re %}
改完需重启推理服务使模板重新加载;操作前先备份原文件。
附:在 WorkBuddy 端也可以把「支持的思考强度」里只勾选
medium/low/xhigh(如果模型支持),避免客户端主动发high。但某些预设仍会带high,所以服务端模板兜底更稳。
坑 2:流式工具调用 arguments 分片,导致 WorkBuddy 网关 400
本节中"工具请求强制非流式"的方案是早期临时方案,已被后文的流式聚合方案替代。
现象
同上 400 报错(JSON decode error / Expecting value / input: {}),但这次不是服务端模板问题,而是 WorkBuddy 网关自己在解析流式响应时崩了。
根因
WorkBuddy 走 SSE 流式 且请求里带 tools 时,vLLM 会把 tool_calls.function.arguments 拆成多片通过 SSE 流出:
text
chunk 1: data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":""}}]}}]}
chunk 2: data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\": "}}]}}]}
chunk 3: data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"23, \"b\": "}}]}}]}
chunk 4: data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"47}"}}]}}]}
WorkBuddy 网关收到每一片时,直接拿 arguments 去做 json.loads()。空串或半截 JSON 就会 Expecting value → 400。这是客户端网关解析 SSE 分片失败,不是 vLLM 拒绝请求。
验证
- 同一请求加
"stream":false后再发,工具调用返回完整 JSON("arguments":"{\"a\": 23, \"b\": 47}"),可正常解析。 - 这就证明问题只在「流式 + 工具调用」组合。
为什么不在 WorkBuddy 关流式?
WorkBuddy 自定义模型配置 UI 没有 stream 开关,无法客户端关闭。
解决方案:加一个极薄转发代理
在 vLLM 前面加一层转发代理:
- 带
tools的请求 → 强制stream=false调 vLLM → 拿到完整结果 → 包成单个 SSE chunk +[DONE]回传。 - 不带
tools的普通对话 → 真流式透传,不影响体验。
这样 WorkBuddy 仍以为是流式,工具调用也能拿到完整 JSON,不再 400。
代理脚本示例(FastAPI + httpx,通用版)
python
# proxy_custom_model.py
import os, json, uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse, Response
import httpx
UPSTREAM = os.environ.get('PROXY_UPSTREAM', 'http://localhost:8000')
API_KEY = os.environ.get('PROXY_API_KEY', '')
PORT = int(os.environ.get('PROXY_PORT', '8900'))
app = FastAPI()
def make_chunk(cid, model, created, delta, finish, usage=None):
return {
'id': cid, 'object': 'chat.completion.chunk',
'created': created, 'model': model,
'choices': [{'index': 0, 'delta': delta, 'finish_reason': finish}],
'usage': usage,
}
def build_tool_chunks(full):
ch0 = (full.get('choices') or [{}])[0]
msg = ch0.get('message', {}) or {}
cid, model, created = full.get('id'), full.get('model'), full.get('created')
finish, usage = ch0.get('finish_reason'), full.get('usage')
d1 = {'role': 'assistant'}
if msg.get('reasoning'):
d1['reasoning'] = msg['reasoning']
if msg.get('tool_calls'):
d1['tool_calls'] = msg['tool_calls']
out = [make_chunk(cid, model, created, d1, None)]
if msg.get('content'):
out.append(make_chunk(cid, model, created, {'content': msg['content']}, None))
out.append(make_chunk(cid, model, created, {}, finish, usage))
return out
def iter_sse(chunks):
for c in chunks:
yield 'data: ' + json.dumps(c, ensure_ascii=False) + '\n\n'
yield 'data: [DONE]\n\n'
@app.api_route('/v1/{path:path}', methods=['GET','POST','OPTIONS','PUT','DELETE'])
async def proxy(path: str, request: Request):
body = await request.body()
try:
payload = json.loads(body) if body else {}
except Exception:
payload = {}
has_tools = isinstance(payload, dict) and bool(payload.get('tools'))
headers = {k: v for k, v in request.headers.items()
if k.lower() not in ('host','content-length','content-type','accept','accept-encoding')}
if API_KEY:
headers['Authorization'] = 'Bearer ' + API_KEY
url = f'{UPSTREAM}/v1/{path}'
# 工具调用:强制非流式,拿到完整结果后包成 SSE
if has_tools:
payload['stream'] = False
async with httpx.AsyncClient(timeout=600) as c:
r = await c.post(url, json=payload, headers=headers)
if r.status_code != 200:
return Response(content=r.content, status_code=r.status_code,
headers={'content-type': r.headers.get('content-type','application/json')})
return StreamingResponse(iter_sse(build_tool_chunks(r.json())), media_type='text/event-stream')
# 普通 GET(如 /v1/models)
if request.method == 'GET':
async with httpx.AsyncClient(timeout=600) as c:
r = await c.get(url, headers=headers)
return Response(content=r.content, status_code=r.status_code,
headers={'content-type': r.headers.get('content-type','application/json')})
# 普通对话:真流式透传
async def _stream():
async with httpx.AsyncClient(timeout=600) as c:
async with c.stream(request.method, url,
json=payload if payload else None,
headers=headers) as r:
async for chunk in r.aiter_raw():
yield chunk
return StreamingResponse(_stream(), media_type='text/event-stream')
if __name__ == '__main__':
uvicorn.run(app, host='0.0.0.0', port=PORT, log_level='info')
启动:
bash
export PROXY_UPSTREAM=http://<VLLM_IP>:<VLLM_PORT>
export PROXY_API_KEY=<API_KEY>
export PROXY_PORT=8900
setsid nohup python3 proxy_custom_model.py > proxy.log 2>&1 &
WorkBuddy 里把 Base URL 改成 http://<SERVER_IP>:8900/v1 即可。
坑 3:Base URL 切到代理端口后报 502
现象
WorkBuddy 报错:
Network error: 502 Server error (proxy: undefined -> http://<IP>:8900)
根因
代理进程虽然在服务器本地正常监听,但服务器防火墙 / 安全组没放行 8900 端口,WorkBuddy 从公网连不进来。
排查
- 在服务器本地:
curl http://localhost:8900/v1/models返回 200(代理正常)。 - 从外部机器:
curl http://<SERVER_IP>:8900/v1/models超时或拒绝连接(防火墙/安全组问题)。
解决
放行端口。以 Ubuntu UFW 为例:
bash
ufw allow 8900/tcp
云服务器还要在控制台安全组里放行 TCP 8900。
坑 4(后续新发现):工具请求带 stream_options,被代理强制非流式后触发 400
现象
配置都正常、对话也通了,但只要请求里带 tools(工具调用),立刻又报 400:
400 Stream options can only be defined when `stream=True`. (parameter=stream_options)
根因
WorkBuddy 在请求里会附带 stream_options(例如 {"include_usage": true},用于让流式响应末尾带上 token 用量统计)。而我们的转发代理在「坑 2」的解法里,对 tools 请求强制把 stream 设成 False 去拿完整结果------但这时 stream_options 还留在 body 里。vLLM 的硬性校验是:stream_options 只有在 stream=True 时才允许存在,于是直接 400。
注意这个 400 和「坑 1」的报错表象不同(这里明确点名 stream_options),定位很快。
解决方案
在代理的「工具请求」分支里,强制非流式的同一时间,把 stream_options 一并摘掉:
python
if has_tools:
payload['stream'] = False
payload.pop('stream_options', None) # vLLM: stream_options only valid when stream=True
...
普通对话(stream=True)路径不受影响,stream_options 原样透传。改完重启代理即可。
坑 5(最隐蔽,也是「不回复」的真凶):代理整段缓冲工具请求,客户端等不到首字节超时
现象
对话 / 工具调用的配置全对、curl 实测也 200,但在 WorkBuddy 里用模型提问,界面一直空白、像没回复;而且「直连 vLLM 能等、走代理就等不到回复」。
根因
问题出在转发代理的「工具请求」分支。该分支对带 tools 的请求强制 stream=False ,等 vLLM 把整轮(思考 15~25 秒 + 正文)全部生成完,才一次性吐出 SSE。在这十几二十秒里,WorkBuddy 一个字节都收不到 → 触发首 token 超时 / 判定无响应,直接白屏或断开。
直连 vLLM 是原生流式,思考片段立刻往外冒,客户端一直有数据,所以「能等」;代理整段缓冲把这条数据流掐断了,所以「不能等」。这也解释了为什么「之前直连能等、加代理后不能等」。
解决方案
「工具分支」也改成从上游流式取,边收边转发 reasoning / content ,只把 tool_calls.arguments(这个字段在流式里会被拆成多片)攒齐后一次性发。这样既保证客户端全程有数据、不会超时,又避免当初为修分片 bug 而强制非流式带来的副作用。核心代码:
python
if has_tools:
payload['stream'] = True # 改回流式:不让客户端干等
payload['stream_options'] = {'include_usage': True}
async def _tool_stream():
tool_acc = {}
async with httpx.AsyncClient(timeout=600) as c:
async with c.stream('POST', url, json=payload, headers=headers) as r:
async for raw in r.aiter_lines():
... # 逐行解析,content / reasoning 立即 yield 转发
# tool_calls 的 arguments 累加进 tool_acc,遇到 [DONE] 时整段 yield 一次
...
return StreamingResponse(_tool_stream(), media_type='text/event-stream')
验证效果(同一工具请求):
- 改前(整段缓冲):首字节要等 15~25 秒;
- 改后(流式透传 + 工具参数整合):TTFB ≈ 4ms ,且
tool_calls.arguments为完整合法 JSON(如{"city": "北京"}),不再分片。
关键结论:代理永远不要对慢思考模型做「整段缓冲 + 末尾一次性返回」;要流式透传,只对确实会分片的字段做服务端整合。
坑 6(当前代理已修复):finish_reason 提前发送,导致 WorkBuddy 误判结束
现象
早期的流式聚合代理虽然已经把 arguments 拼成完整 JSON,但实际 SSE 顺序是:
text
finish_reason: "tool_calls"
usage
tool_calls(完整参数)
[DONE]
WorkBuddy 收到 finish_reason 后会认为本轮已经结束,随后可能发出空请求,最终表现为"不回复"或 400 JSON decode error。
当前修复
代理文件:/data/lost+found/sjh/VLLM/proxy_qwen3.8.py
代理端口:8900
上游 vLLM:http://localhost:8998
当前工具调用分支的正确顺序是:
text
reasoning/content(实时转发)
tool_calls(arguments 已完整拼接,并包含 index)
finish_reason: "tool_calls"
[DONE]
具体修复包括:
- 暂缓转发上游的
finish_reason,直到完整工具调用已经发送; - 聚合
tool_calls.function.arguments后一次性发送; - 补充
tool_calls[].index; - 延后 usage-only SSE chunk,避免严格客户端提前结束;
- 不再对工具请求做整段
stream=false缓冲,思考内容仍实时输出。
修复后使用带 tools、reasoning_effort、stream_options、stream=true 的请求实测,输出顺序正确,arguments 是完整合法 JSON。
四、关键结论与速查表
| 报错 | 真实位置 | 根因 | 解法 |
|---|---|---|---|
400 JSON decode error / Expecting value / input: {} |
服务端 vLLM | reasoning_effort: "high" 不被 Qwen3 模板接受 |
改 chat_template.jinja,high 映射为 xhigh |
400 JSON decode error / Expecting value / input: {} |
WorkBuddy 网关或中间代理 | 流式工具调用 arguments 分片,或代理先发 finish_reason 后发工具调用 |
代理实时转发 reasoning/content,聚合完整 arguments,先发 tool_calls 再发 finish_reason |
502 Server error (proxy: undefined -> ...:8900) |
网络层 | 防火墙/安全组未放行 8900 | ufw allow 8900/tcp + 安全组放行 |
400 ... Stream options can only be defined when stream=True (parameter=stream_options) |
服务端 vLLM | 工具请求被代理强制非流式,但 stream_options 未摘掉 |
代理「工具分支」里 payload.pop('stream_options', None) 后重启 |
| 界面一直空白 /「不回复」(错误码可能无,仅超时或白屏) | 客户端 WorkBuddy | 代理「工具分支」对 tools 请求整段缓冲(stream=False),慢思考模型 15~25 秒无字节 → 客户端首 token 超时 |
代理工具分支改回 stream=True 流式透传,仅攒齐 tool_calls.arguments 后一次性发(见坑 5) |
WorkBuddy 配置检查清单
- Base URL 以
/v1结尾 - API Key 与服务端
--api-key一致 - 若用了代理,Base URL 指向代理端口(如
:8900),而非裸 vLLM 端口 - 代理所在端口已在防火墙/安全组放行
- 模型名称与服务端
/v1/models返回的一致
五、一句话总结
WorkBuddy 自定义模型走 OpenAI 兼容协议时,流式是默认且不可关的 ;对 Qwen3 系模型要特别注意 reasoning_effort 参数映射,对工具调用要处理 SSE 分片和事件顺序。当前推荐的接入方式是在推理服务前加一个极薄代理:普通内容实时透传,只聚合 tool_calls.arguments,并保证先发完整工具调用、再发 finish_reason。同一个 400 表象下可能藏着多层不同根因,定位时务必先抓真实日志、再直接抓流式响应看事件顺序。