LLM推理降本的工程路径:别让模型「想太多」

0 阅读12分钟

一、你的模型,在浪费多少 Token?

有个现象值得关注:给 o1 或 DeepSeek-R1 这类推理模型出一道简单的算术题,它会先花两三百个 token 自言自语一番,推演各种可能性,最终给出早就"知道"的答案。

这不是 bug,是设计使然——Chain-of-Thought 的本质就是靠更长的上下文换更高的准确率。问题在于:这个"换算比"在不同任务上差异极大。对于高难度推理题,多思考 500 token 可能把准确率从 60% 拉到 90%;对于一个简单的格式转换请求,多思考 500 token 只是在烧钱。

ArXiv 这周有篇论文直接点了这个痛点:ROM(Real-time Overthinking Mitigation),通过流式检测推理过程,在模型"已经找到答案"后主动介入截断,实测可以在不显著损失准确率的前提下,把推理 token 消耗削减 30%~50%。

这当然只是问题的一个切面。推理成本的来源是多维的:过度思考是一个,Vision-Language Model 里冗余的视觉 token 是另一个,服务端的调度策略又是一个。今天把这几条线串起来,讲讲工程侧实际能做什么。

二、过度思考:从现象到工程干预

2.1 过度思考是什么样的

先来建立直觉。让一个推理模型判断"北京是中国首都吗",它的 thinking trace 可能长这样:


The user is asking whether Beijing is the capital of China.
Let me think about this carefully. China is a country in East Asia.
The capital city... I recall that Beijing has been the capital since 1949.
But wait, was there any period when the capital changed? Shanghai? Nanjing?
Historically, Nanjing was a capital during the Republic of China period...
But the People's Republic was founded in 1949 and Beijing has been the capital since.
Yes, Beijing is definitely the capital of China.

是的,北京是中国的首都。

注意那段"But wait"之后的自我纠正——模型在确认答案之后,仍然在继续"反刍"。这是 LRM(Large Reasoning Model)的典型行为:训练时用 RLVR 强化了"多想"的奖励,导致模型在推理阶段倾向于过度生成。

2.2 早停检测:工程实现思路

ROM 的核心思路是流式检测置信信号。在推理 token 流中,周期性地对已生成的 thinking trace 提取特征,判断模型是否已经"收敛"到答案。一旦检测到收敛信号,立即注入终止 token(如 </think>)中断推理。

这在工程上的挑战在于:你得在不完整的推理链上实时判断质量,误判会直接导致答案出错。ROM 的做法是用一个轻量探针模型(几十 M 参数)对当前 trace 做分类,成本远低于主模型推理本身。

如果你是在自托管推理服务(比如 vLLM),可以在 generate 循环里加 logit processor 做类似的事:

from vllm import LLM, SamplingParams
from transformers import LogitsProcessor
import torch

class EarlyStopProcessor(LogitsProcessor):
    """
    检测 thinking trace 是否已收敛,若是则强制输出  token
    """
    def __init__(self, think_end_token_id: int, probe_model, threshold: float = 0.85):
        self.think_end_token_id = think_end_token_id
        self.probe_model = probe_model
        self.threshold = threshold
        self.current_trace = []
        self.in_thinking = True
        self.check_interval = 50  # 每 50 个 token 检测一次
        self.step = 0

    def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor):
        if not self.in_thinking:
            return scores

        self.step += 1
        self.current_trace.append(input_ids[0, -1].item())

        if self.step % self.check_interval == 0:
            # 调用探针模型判断是否收敛
            confidence = self.probe_model.check_convergence(self.current_trace)
            if confidence > self.threshold:
                # 强制下一个 token 为 
                forced = torch.full_like(scores, float('-inf'))
                forced[:, self.think_end_token_id] = 0.0
                self.in_thinking = False
                return forced

        return scores

对于直接调用 API 的场景(OpenAI、Anthropic),工程上没有这么细粒度的控制。这时候更实用的方案是 budget forcing:在 system prompt 里加一条约束。

# 给 o3/DeepSeek-R1 用的 system prompt 片段
system_prompt = """
You are a helpful assistant. 

IMPORTANT: Keep your thinking concise and focused.
- For factual questions: think for at most 3-5 sentences
- For coding tasks: outline your approach in  B{任务分类器}
    B -->|简单/格式化/事实性| C[小模型 7B\nGlm-4-Flash / Qwen2.5-7B]
    B -->|中等复杂度| D[标准模型\nGPT-4o-mini / Qwen2.5-72B]
    B -->|需要深度推理| E[推理模型\nDeepSeek-R1 / o3]
    C --> F[返回结果]
    D --> F
    E --> F
    style C fill:#d4edda,stroke:#28a745
    style D fill:#fff3cd,stroke:#ffc107
    style E fill:#f8d7da,stroke:#dc3545

这个分类器本身不需要复杂,一个 BERT-base 级别的文本分类模型就能做,或者直接用规则(关键词 + 问题长度 + 历史 token 消耗统计)。关键是要有 fallback 机制:分类器置信度低时升级到更强的模型。

在成本上,这类路由策略实测可以把整体 token 消耗降低 60% 以上——大量日常请求(查询、总结、格式转换)根本不需要 o3 级别的能力。

三、视觉 Token 的冗余问题:VLM 的隐藏成本

如果你的场景涉及多模态,有另一个成本黑洞值得注意:VLM(Vision-Language Model)的视觉 token 数量。

以 LLaVA-1.5 为例,一张 336×336 的图片编码后产生 576 个视觉 token。GPT-4V 的 high-detail 模式下,一张 1024×1024 的图会被切成多个 tile,最终产生超过 1000 个视觉 token。这些 token 在 attention 中会和所有文本 token 交互,计算量是 O(n²) 的。

3.1 视觉 Token 剪枝:ResPrune 的思路

ArXiv 同期的 ResPrune 给了一个有意思的角度:不是在编码阶段压缩图像,而是在 LLM 的中间层,根据文本 query 对视觉 token 做动态剪枝。

核心假设是:不同的问题关注图片的不同区域。"图里的人在做什么"和"图里有什么文字",两个问题需要保留的视觉 token 集合差异很大。ResPrune 用文本 query 作为条件,通过子空间重建的方式,找出当前 query 下"信息量最少"的视觉 token 并丢弃。

工程上实现这类剪枝,最直接的切入点是在 LLM 的 attention layer 里加 hook:

import torch
import torch.nn as nn

class VisualTokenPruner:
    """
    基于 attention score 的视觉 token 动态剪枝
    在指定层后移除 attention 权重最低的视觉 token
    """
    def __init__(self, model, visual_token_range: tuple, keep_ratio: float = 0.5, prune_layer: int = 8):
        """
        visual_token_range: (start_idx, end_idx) 视觉 token 在序列中的位置
        keep_ratio: 保留比例
        prune_layer: 在第几层之后执行剪枝(前几层保留完整,让模型充分理解图像)
        """
        self.model = model
        self.visual_start, self.visual_end = visual_token_range
        self.keep_ratio = keep_ratio
        self.prune_layer = prune_layer
        self.attention_scores_cache = {}
        self._register_hooks()

    def _register_hooks(self):
        for idx, layer in enumerate(self.model.model.layers):
            if idx == self.prune_layer:
                layer.register_forward_hook(self._prune_hook)

    def _prune_hook(self, module, input, output):
        hidden_states = output[0]  # [batch, seq_len, hidden_dim]
        batch_size, seq_len, hidden_dim = hidden_states.shape

        # 取视觉 token 的 hidden states
        visual_hidden = hidden_states[:, self.visual_start:self.visual_end, :]

        # 用 L2 norm 近似 token 重要性
        importance = visual_hidden.norm(dim=-1)  # [batch, num_visual_tokens]

        num_visual = self.visual_end - self.visual_start
        keep_n = int(num_visual * self.keep_ratio)

        # 找出重要性最高的 token 索引
        _, top_indices = importance.topk(keep_n, dim=-1)  # [batch, keep_n]
        top_indices, _ = top_indices.sort(dim=-1)  # 保持位置顺序

        # 重建 hidden_states(用剪枝后的视觉 token 替换原始的)
        pruned_visual = torch.gather(
            visual_hidden,
            dim=1,
            index=top_indices.unsqueeze(-1).expand(-1, -1, hidden_dim)
        )

        new_hidden = torch.cat([
            hidden_states[:, :self.visual_start, :],
            pruned_visual,
            hidden_states[:, self.visual_end:, :]
        ], dim=1)

        # 返回修改后的 output
        return (new_hidden,) + output[1:]

📌 ⚠️ 注意:上面的代码是演示剪枝思路的简化版本。实际生产中,attention mask 和 position embedding 都需要随着 token 数量同步更新,否则会产生对齐错误。且不同 VLM 架构(LLaVA、InternVL、Qwen-VL)的视觉 token 位置逻辑差异较大,需要分别适配。

3.2 更务实的方案:调整图像输入策略

上面的剪枝方案需要改模型推理代码,成本较高。如果你用的是 API,更务实的优化在输入侧

降低图像分辨率:对于"图里有没有人""判断物体类别"这类粗粒度任务,把图片缩到 512px 以内,token 消耗可以减少 75%,准确率影响极小

裁剪 ROI:如果业务逻辑知道关注区域(比如表单识别只看特定区域),先用传统图像处理裁剪再送给 VLM

选择合适的 detail 模式:GPT-4V 的 detail: "low" 模式固定 85 个 token,对很多任务已经够用

from PIL import Image
import base64
from io import BytesIO

def prepare_image_for_vlm(
    image_path: str,
    task_type: str,  # "coarse" | "fine_grained" | "ocr"
    max_size_map: dict = {"coarse": 512, "fine_grained": 1024, "ocr": 1024}
) -> str:
    """根据任务类型自适应调整图像大小,返回 base64 编码"""
    img = Image.open(image_path)
    max_size = max_size_map.get(task_type, 768)

    # 等比缩放
    w, h = img.size
    if max(w, h) > max_size:
        ratio = max_size / max(w, h)
        img = img.resize((int(w * ratio), int(h * ratio)), Image.LANCZOS)

    buf = BytesIO()
    img.save(buf, format="JPEG", quality=85)
    return base64.b64encode(buf.getvalue()).decode()

# 使用示例
img_b64 = prepare_image_for_vlm("screenshot.png", task_type="coarse")

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{
        "role": "user",
        "content": [
            {"type": "image_url", "image_url": {
                "url": f"data:image/jpeg;base64,{img_b64}",
                "detail": "low"  # 固定 85 token,适合粗粒度任务
            }},
            {"type": "text", "text": "这张图片里有人吗?"}
        ]
    }]
)

四、KV Cache 工程:省的不是 token,是时间

前面说的是减少 token 生成数量,从而降低计算量和 API 费用。但在延迟优化上,还有另一条路:KV Cache

Transformer 推理的本质是每个 token 的生成都依赖之前所有 token 的 Key/Value 向量。KV Cache 把这些向量缓存下来,避免重复计算。在自托管推理服务(vLLM、TGI、SGLang)上,KV Cache 的管理方式直接影响 throughput 和 TTFT(Time To First Token)。

4.1 Prefix Caching:让重复前缀只计算一次

在很多业务场景里,大量请求共享相同的前缀——system prompt、few-shot examples、知识库片段。如果每次都重新计算这些 token 的 KV,是显著的浪费。

vLLM 的 enable_prefix_caching 解决了这个问题。原理是把已计算的 KV block 按 token 哈希缓存,新请求进来先做前缀匹配,命中则直接复用:

# vLLM 启动时开启 prefix caching
python -m vllm.entrypoints.openai.api_server \
    --model Qwen/Qwen2.5-72B-Instruct \
    --enable-prefix-caching \
    --max-model-len 32768 \
    --gpu-memory-utilization 0.9 \
    --tensor-parallel-size 4

对于 system prompt 长的场景(比如 RAG 里把大量文档塞进 context),prefix caching 的收益非常明显。实测在 system prompt 占总 token 60%+ 的场景下,TTFT 可以下降 40%~70%。

但要注意一个陷阱:prefix caching 要求前缀的 token 序列完全一致(包括特殊 token 和 chat template 格式)。如果你在 system prompt 里动态插入时间戳或用户 ID,前缀每次都不同,缓存命中率为零。

# ❌ 这样写会导致 prefix caching 失效
system_prompt = f"""
You are a helpful assistant. Current time: {datetime.now()}.
User ID: {user_id}. ...
"""

# ✅ 把动态信息移到 user message,保持 system prompt 静态
system_prompt = """
You are a helpful assistant. You will be given context at the start of each message.
"""

user_message = f"""
[Context: User={user_id}, Time={datetime.now()}]

{actual_user_query}
"""

4.2 Radix Attention:SGLang 的进阶方案

vLLM 的 prefix caching 是 token 级别的哈希匹配。SGLang 的 Radix Attention 更进一步,用一棵基数树(Radix Tree)管理所有请求的 KV cache,支持多请求之间的任意公共前缀共享,不只是 system prompt,还包括 few-shot examples、RAG 检索结果等各种共享子序列。

选择上的判断:

方案适用场景主要优势局限
vLLM Prefix Cache固定 system prompt 场景部署简单,社区成熟只匹配固定前缀
SGLang Radix AttnRAG / few-shot / 多轮对话任意公共前缀共享,命中率更高内存管理更复杂
Anthropic Prompt Cache调用 Claude API无需自托管需手动标记 cache_control

Anthropic 的 Prompt Caching 值得单独提一下——它需要在请求里显式标记哪些部分希望被缓存,缓存 TTL 5 分钟,命中时输入 token 费用降低 90%。对于 context 很长的知识库检索场景,这是非常直接的降本手段。

# Anthropic Prompt Caching 使用示例
response = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=1024,
    system=[
        {
            "type": "text",
            "text": LONG_KNOWLEDGE_BASE_TEXT,  # 几千 token 的知识库内容
            "cache_control": {"type": "ephemeral"}  # 标记缓存
        }
    ],
    messages=[{
        "role": "user",
        "content": user_query  # 这部分不缓存,每次不同
    }]
)

# 查看缓存命中情况
usage = response.usage
print(f"Cache write tokens: {usage.cache_creation_input_tokens}")
print(f"Cache read tokens: {usage.cache_read_input_tokens}")
print(f"Regular input tokens: {usage.input_tokens}")

五、把这些拼起来:一个务实的降本架构

上面讲了三个维度:减少推理 token(过度思考干预)、减少视觉 token(VLM 输入优化)、复用计算(KV Cache)。实际工程里,这三个要组合使用才有明显效果。

flowchart TD
    A[用户请求] --> B[任务分类层]
    B --> C{任务类型}
    C -->|纯文本-简单| D[小模型\n无 CoT]
    C -->|纯文本-复杂| E[推理模型\n+ Budget Forcing]
    C -->|含图片| F[图像预处理\n分辨率自适应]
    F --> G[VLM\n视觉token剪枝]
    D --> H[推理服务层\nvLLM / SGLang]
    E --> H
    G --> H
    H --> I{Prefix Cache\n命中?}
    I -->|命中| J[直接返回\nKV复用]
    I -->|未命中| K[全量计算\n写入Cache]
    K --> L[结果]
    J --> L
    style D fill:#d4edda,stroke:#28a745
    style E fill:#f8d7da,stroke:#dc3545
    style I fill:#fff3cd,stroke:#ffc107

几个实际落地的建议:

先做任务路由,收益最大:不需要改推理代码,只需要一个轻量分类器。大部分生产流量里,简单任务占 60%+ ,用小模型处理可以直接省掉大头费用。

Prefix caching 几乎零成本,但要检查 system prompt 是否稳定:很多团队开了这个 flag 但实际命中率很低,根源是 system prompt 里有动态内容。日志里观察 prefix_cache_hit_rate 来确认。

推理 token 控制要按任务类型区分:对于代码生成、数学推理,不要激进地砍推理 token,准确率损失不值得。对于问答、摘要、分类,可以大刀阔斧。

VLM 的图像分辨率策略往往被忽视:花 10 分钟加一个分辨率自适应的预处理函数,可能比 3 天的模型优化工作省更多费用。

实测数据参考:某 RAG 问答系统在同时开启任务路由 + prefix caching + system prompt 静态化后,月度 API 费用下降约 68%,p50 延迟下降 45%。单项优化的收益远不如组合来得显著。

六、下一步值得探索的方向

有一个方向是这篇没展开的:Speculative Decoding(推测解码)。它用一个小的 draft model 先生成候选 token,再用大模型批量验证,把原本串行的 token 生成变成并行验证,在 throughput 上可以有 2-3x 的提升。和本文讨论的降 token 数量的思路是正交的,可以叠加使用。

另一个值得关注的是 MLA(Multi-head Latent Attention),DeepSeek-V2/V3 引入的结构,通过低秩压缩 KV cache,在保持模型能力的前提下把 KV cache 内存占用降低到原来的 1/5 左右。这不只是工程优化,而是架构层面的变化,意味着未来的模型可以在同等 GPU 内存下服务更长的 context 或更多的并发请求。

推理成本这件事,在过去两年里一直在快速下降(Jevons 悖论同时也在发生——需求增速更快)。作为工程师,理解这些优化机制不只是为了省钱,更是为了在下一轮能力跃升时,知道系统的瓶颈在哪里。