这是「AI 模型本地部署」系列的第三篇。前两篇结束时,你有了一个能从外网访问的推理服务。这一篇讲怎么真正用起来——接进浏览器翻译扩展、代码编辑器,以及自己写的程序。
其中有一个技巧能让翻译类任务的 token 消耗降到九分之一,还有两个”看起来是服务端不支持、其实是客户端配置问题”的坑,都会讲清楚怎么判断。
本地大模型部署系列(共 4 篇):① 选型与环境 → ② 对外暴露 → ③ 客户端接入 → ④ 知识库 RAG。本文是第 ③ 篇。
先理解一件事:思考模式
现在很多开源模型(Gemma 4、Qwen3 等)带思考模式:先输出一段推理,再给正式答案。推理引擎会把两者分开:
{
"choices": [{
"finish_reason": "length",
"message": {
"content": "",
"reasoning_content": "Thinking Process:\n\n1. Analyze the request..."
}
}],
"usage": { "completion_tokens": 150 }
}
思考也算 completion tokens。上面这个例子里 150 个 token 全被思考吃掉了,content 是空字符串——不是 null,不报错。只读 content 的客户端会显示”模型没有回答”。
两条应对:
- 需要推理的任务(写代码、分析问题):把
max_tokens给到 2048 以上,并在客户端处理reasoning_content。 - 不需要推理的任务(翻译、摘要、分类):直接关掉思考。
怎么关掉思考
试过两种写法,只有一种有效:
// 无效 —— 实测照样思考了 1239 字
{ "reasoning_budget": 0 }
// 有效 —— 思考 0 字
{ "chat_template_kwargs": { "enable_thinking": false } }
差距有多大?同一段技术文字翻译成中文:
| 思考 | 输出 token | |
|---|---|---|
| 默认 | 1762 字 | 560 |
| 关闭思考 | 0 | 61 |
token 差 9 倍,译文质量没有区别。
给”不能自定义请求体”的工具开一条专用路径
问题来了:浏览器翻译扩展这类工具只让你填 base_url、API Key 和模型名,塞不进 chat_template_kwargs。
解法是在服务端加一层转发,自动注入这个参数。下面是一个几十行的 FastAPI 实现:
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import Response, StreamingResponse
import httpx
app = FastAPI()
UPSTREAM = "http://127.0.0.1:8000"
NO_THINK = {"enable_thinking": False}
def upstream_auth(request: Request) -> dict:
"""把调用方的 Authorization 原样带给推理服务。
没带就什么都不带,让推理服务返回 401。
绝不能回退成服务端自己的 Key —— 那样这条路径就成了公网上的无鉴权入口。
"""
auth = request.headers.get("authorization")
return {"Authorization": auth} if auth else {}
@app.post("/nt/v1/chat/completions")
async def no_think_chat(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="请求体不是合法 JSON")
# 调用方自己指定了就尊重它,只补上缺的 enable_thinking
kwargs = body.get("chat_template_kwargs")
body["chat_template_kwargs"] = (
{**NO_THINK, **kwargs} if isinstance(kwargs, dict) else dict(NO_THINK)
)
headers = upstream_auth(request)
headers["Content-Type"] = "application/json"
if not body.get("stream"):
async with httpx.AsyncClient(timeout=600.0) as c:
r = await c.post(f"{UPSTREAM}/v1/chat/completions", json=body, headers=headers)
return Response(content=r.content, status_code=r.status_code,
media_type=r.headers.get("content-type", "application/json"))
async def relay():
async with httpx.AsyncClient(timeout=600.0) as c:
async with c.stream("POST", f"{UPSTREAM}/v1/chat/completions",
json=body, headers=headers) as r:
async for chunk in r.aiter_raw():
yield chunk
return StreamingResponse(relay(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
那个 upstream_auth 的注释是血泪教训。我第一版写的是”没有 Authorization 头就用服务端自己的 Key”,理由是”方便本机调试”。结果这条路径变成了公网上完全不需要鉴权的入口——本机测永远是通的,只有从外网发一个无 Key 请求才会暴露。
在 nginx 里给它开一个 location(别落到面板那种低限流的区域):
location /nt/ {
limit_req zone=llm_api burst=300 nodelay;
proxy_pass http://127.0.0.1:8080; # 上面那个 FastAPI 服务
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 900s;
}
接入浏览器翻译扩展
以沉浸式翻译为例,翻译服务选「自定义 API / OpenAI 兼容」:
| 字段 | 填什么 |
|---|---|
| API Key | 你的 Key |
| 自定义 API 地址 | https://你的域名/nt/v1/chat/completions |
| 模型名 | 你在引擎里设的 alias |
| 每秒最大请求数 | 和后端并发槽位数一致(比如 4) |
| 每次请求最大段落数 | 10 左右 |
地址一定要用关思考的那条。用普通 /v1 的话每段都会先思考几百个 token,慢一个数量级,而且思考内容有几率被当成译文插进页面。
system prompt 建议写得明确一点,让模型照做而不是发挥:
You are a translation engine. Translate to Chinese. Output only the translation.
如果扩展报”网络连接失败”
先别查网络。去看服务端的 nginx 错误日志:
sudo tail -20 /var/log/nginx/<你的站点>.error.log
我遇到过的实际原因是限流:
[error] limiting requests, excess: 40.800 by zone "llm_api",
request: "POST /nt/v1/chat/completions"
翻译一页会瞬间发几十个请求,我当时设的 rate=120r/m(每秒 2 个)+ burst=40,burst 一下就满,后续全部 503——扩展把 503 报成了”网络连接失败”。
限流的目的是防 Key 泄露后被滥用,不是节流正常使用,值要远高于真实负载。真正约束正常使用的是后端的并发槽位(超了会排队,不会丢请求)。
接入代码编辑器
大部分支持自定义 OpenAI 兼容 provider 的编辑器(OpenCode、Continue、Cursor 等)都是填 base_url + Key + 模型名。但有一个坑:很多编辑器默认把自定义 provider 当作纯文本模型,需要显式声明能力,否则图片附件会被静默丢掉。
以 OpenCode 为例:
{
"provider": {
"myllm": {
"npm": "@ai-sdk/openai-compatible",
"name": "My Local LLM",
"options": {
"baseURL": "https://你的域名/v1",
"apiKey": "sk-..."
},
"models": {
"gemma4": {
"name": "Gemma 4",
"attachment": true,
"modalities": { "input": ["text", "image"] },
"reasoning": true,
"tool_call": true,
"limit": { "context": 32768, "output": 8192 }
}
}
}
}
}
怎么判断图片是被谁丢掉的
如果不声明 attachment 和 modalities,客户端会在发送前把图片剥掉,换成一句”不支持图片输入”的文字。模型收到的就是那句话,于是照着复述——看起来像服务端不支持,其实请求根本没发出来。
一条命令就能分清:
sudo tail -5 /var/log/nginx/<你的站点>.access.log
没有对应记录 = 客户端拦的;有记录且返回 200 = 服务端正常,问题在客户端怎么解析响应。
还有一个容易混淆的点
多模态模型吃的是图片,不解析 PDF。模型说自己读不了 PDF 通常是准确的,不是 bug。要处理 PDF 得客户端先渲染成图片再传。所以 modalities 里不要写 "pdf"——写了客户端就会真的把 PDF 发过来,后端只会报错。
另外,写代码用普通 /v1(带思考),别用关思考那条——后者是给”照做就行”的任务准备的。
自己写客户端
用官方 OpenAI SDK 即可,改 base_url 就行。下面这份包含了前面提到的所有注意点:
import os, sys, base64
from openai import OpenAI
client = OpenAI(
base_url=os.environ.get("LLM_BASE_URL", "https://你的域名/v1"),
api_key=os.environ["LLM_API_KEY"],
)
MODEL = "gemma4"
def show_model():
m = client.models.list().data[0]
# 上下文长度:不同引擎放的位置不一样
meta = getattr(m, "meta", None) or {}
ctx = getattr(m, "max_model_len", None) or (
meta.get("n_ctx") if isinstance(meta, dict) else None)
print(f"模型 {m.id},上下文 {ctx or '未知'}")
def chat(prompt: str):
r = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
# 思考也算 completion tokens,给小了 content 会是空字符串
max_tokens=2048,
)
msg = r.choices[0].message
print(msg.content or "(空)")
if getattr(msg, "reasoning_content", None):
print(f" [思考了 {len(msg.reasoning_content)} 字]")
def stream(prompt: str):
s = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
max_tokens=2048, stream=True,
)
thinking = 0
for chunk in s:
if not chunk.choices:
continue
d = chunk.choices[0].delta
# 不处理 reasoning_content 的话,思考的几十秒里一个字都收不到,
# 看起来像卡死
if getattr(d, "reasoning_content", None):
if thinking == 0:
print("[思考中", end="", flush=True)
thinking += 1
if thinking % 20 == 0:
print(".", end="", flush=True)
if d.content:
if thinking:
print("]"); thinking = 0
print(d.content, end="", flush=True)
print()
def structured(prompt: str):
"""强制输出符合 JSON Schema。
用 OpenAI 标准的 response_format —— 各家引擎都认。
别混着传引擎私有参数(有的叫 json_schema,有的叫 guided_json),
混传会让某些引擎返回空 content。
"""
schema = {
"type": "object",
"properties": {"city": {"type": "string"}, "lat": {"type": "number"}},
"required": ["city", "lat"],
}
r = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
max_tokens=2048,
response_format={"type": "json_schema",
"json_schema": {"name": "loc", "schema": schema}},
)
print(r.choices[0].message.content or "(空)")
def with_image(path: str, question: str):
mime = "image/png" if path.lower().endswith(".png") else "image/jpeg"
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
r = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": [
{"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"}},
{"type": "text", "text": question},
]}],
max_tokens=2048,
)
print(r.choices[0].message.content)
用 curl 时的一个小陷阱
流式响应里 delta.content 是显式的 null(不是缺少这个键),所以 Python 里 d.get("content", "") 会返回 None 并打印出 “None”。要写成:
print(d.get("content") or "", end="")
另外传 base64 图片时,别把它拼进 python3 -c "..." 的字符串里——base64 含 / + =,多层引号嵌套很容易炸。用环境变量传:
IMG_URL="data:image/png;base64,$(base64 < pic.png | tr -d '\n')" \
python3 -c '
import json, os
print(json.dumps({
"model": "gemma4", "max_tokens": 2048,
"messages": [{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": os.environ["IMG_URL"]}},
{"type": "text", "text": "描述这张图片"}]}]
}))' > /tmp/req.json
curl -sS "$BASE/v1/chat/completions" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
--data @/tmp/req.json
接入其他工具的通用要点
- Open WebUI / LobeChat:选 OpenAI 兼容,填 base_url 和 Key 即可。
- LangChain:
ChatOpenAI(base_url=..., api_key=..., model=...)。 - 带非标准端口时注意:很多工具的输入框会吞掉端口号,填完存一下再打开确认。
- 模型名要和引擎里的 alias 一致,不是 HuggingFace 上那个全名。
排查速查表
| 现象 | 先查什么 |
|---|---|
| 回答为空 | 响应里有没有 reasoning_content;调大 max_tokens |
| “网络连接失败” | nginx error log,多半是限流 |
| 图片被忽略 | access log 有没有记录;没有就是客户端拦的 |
| 流式变成一次性输出 | 某一层开了缓冲,检查 proxy_buffering |
| 401 | Key 对不对;转发层有没有透传 Authorization |
| 模型名 404 | 用引擎的 alias,不是仓库全名 |
下一步
到这里模型已经融进日常工作流了。系列最后一篇会给它加上知识库——让它能基于你自己的文档回答问题,包括切分策略、向量库选型,以及中文检索特有的两个问题。