把本地大模型接进日常工具:翻译扩展、代码编辑器与自定义客户端
把本地大模型接进日常工具:翻译扩展、代码编辑器与自定义客户端
站内搜索
直接问 AI

把本地大模型接进日常工具:翻译扩展、代码编辑器与自定义客户端

这是「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 }
        }
      }
    }
  }
}

怎么判断图片是被谁丢掉的

如果不声明 attachmentmodalities,客户端会在发送前把图片剥掉,换成一句”不支持图片输入”的文字。模型收到的就是那句话,于是照着复述——看起来像服务端不支持,其实请求根本没发出来

一条命令就能分清:

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 即可。
  • LangChainChatOpenAI(base_url=..., api_key=..., model=...)
  • 带非标准端口时注意:很多工具的输入框会吞掉端口号,填完存一下再打开确认。
  • 模型名要和引擎里的 alias 一致,不是 HuggingFace 上那个全名。

排查速查表

现象 先查什么
回答为空 响应里有没有 reasoning_content;调大 max_tokens
“网络连接失败” nginx error log,多半是限流
图片被忽略 access log 有没有记录;没有就是客户端拦的
流式变成一次性输出 某一层开了缓冲,检查 proxy_buffering
401 Key 对不对;转发层有没有透传 Authorization
模型名 404 用引擎的 alias,不是仓库全名

下一步

到这里模型已经融进日常工作流了。系列最后一篇会给它加上知识库——让它能基于你自己的文档回答问题,包括切分策略、向量库选型,以及中文检索特有的两个问题。

发表回复

向下探索