你大概已经能让 llama.cpp 吐出 token,也能用 Python 把向量库查通,但两块拼起来就开始出事:首次请求慢到超时、批量提问直接把本地推理打满、检索到空结果模型照样编。这篇不讲 RAG 原理也不讲协议兼容,只按工程落地顺序,把 llama.cpp 服务层和 Python 检索层之间的衔接点一项项说清,让你搭出一条能观测、能兜底、能评测的最小链路。
你大概已经能让 llama.cpp 吐出 token,也能用 Python 把向量库查通,但两块拼起来就开始出事:首次请求慢到超时、批量提问直接把本地推理打满、检索到空结果模型照样编。问题多半不在模型,也不在向量库,而在两层之间的衔接没定规矩。
这篇不讲 RAG 原理,也不讲协议兼容,只按工程落地顺序,把服务边界、超时并发、检索衔接、日志评测这几件事一项项说清,让你搭出一条能观测、能兜底、能换零件的最小链路。想先确认自己机器的延迟基线,可以到 /tools/speed-test 量一遍再往下看。
一、先把推理服务的边界定清楚
llama.cpp 这一层只干一件事:拿到拼好的 prompt,吐出文本。分块、拼模板、写日志、调向量库,全都放 Python 侧。两边用 HTTP 或本地进程调用隔开,好处是能单独压测,也能在不改检索代码的前提下换掉推理后端。混在一起写,最后一定是改一处崩两处。
- 服务形态
- 常驻进程还是按需拉起,决定冷启动会不会计入首字延迟
- 上下文预算
- 留给检索片段多少 token,直接决定能塞几条、截断策略怎么写
- 生成方式
- 流式还是整段返回,决定前端超时和 Python 侧读取逻辑
- 空闲回收
- 常驻不动会一直占着资源,回收太激进又会反复冷启动
这三项不是拍脑袋定的,而是相互牵制。上下文预算给大,首 token 就慢,超时阈值得跟着放宽;改成按需拉起,首字延迟里要多算一次加载时间。常驻还是按需、窗口留多少,跟你手上的硬件规格强相关,具体口径到 /models/deploy 对照当时的资料再定,别照抄别人的配置。
二、超时和并发:本地推理最容易踩的两个坑
- 把超时拆成三段建连、首 token、整体生成分别设阈值。本地模型首 token 慢是常态,一个统一超时值必然误杀长上下文请求。验证方法:故意发一条超长 prompt,看它挂在哪一段被掐掉。
- 并发用有界队列加信号量别拿线程池硬怼。单实例的并行槽位有限,同时在跑的请求数用信号量卡死,超出的排队而不是直接报错。验证方法:压测时观察排队时长是否线性增长。
- 降级顺序写死本地超时先重试一次;仍失败就退回「只拼检索结果、不调模型」的纯资料回答;再不行返回明确错误码。判断依据是错误类型,不是重试次数。
- 先量基线再填数字用 /tools/speed-test 先跑出你这台机器的排队、首 token、总耗时基线,超时阈值在这上面留余量,别凭感觉填。
三、Python 检索层与生成的衔接
检索结果进 prompt 之前必须先裁剪:按 token 预算截断、去掉高度重复的片段、每条带上来源标识。片段塞太多不只是变慢,还会把对话历史挤出去,导致模型答非所问。这一段的阈值没有通用答案,跟你选的分块大小绑在一起。
| 衔接点 | 常见错误 | 判断标准 |
|---|---|---|
| 片段裁剪 | 整段塞进去不管预算 | 超出预算先砍低分片段,保留来源编号 |
| 空召回 | 把空字符串拼进 prompt | 命中分数低于阈值就直接说没找到 |
| Prompt 模板 | 每次拼法都不一样 | 固定为系统指令、编号片段、用户问题三块 |
| 日志字段 | 只记了最终回答 | 检索耗时、命中条数、分数、生成耗时都要落盘 |
空召回一定要有独立分支。别把空结果拼进 prompt 就发出去,模型会顺着问题直接开始编,而且编得挺像。正确做法是命中分数低于你设的阈值时,明确返回「没找到相关资料」,让用户知道这是检索的问题,不是模型的问题。
- 片段编号要和日志里的 chunk id 一一对应,否则事后没法定位是哪一条害了回答
- 每次请求把检索耗时、命中条数、命中分数、生成耗时、输出长度写进一条结构化日志
- 排查答错时先问自己一句:是没查到,还是查到了模型没用上
- 跨文档、表格类问题单独标记,它们在两种层次上的失败率往往不一样
四、日志与离线评测的最小闭环
日志字段不齐,后面所有复盘都是空谈。固定这几项:request_id、原始问题、命中 chunk id 与分数、prompt token 数、首 token 耗时、总耗时、是否走了降级、错误类型。有了 request_id,一次请求的检索段和生成段才能串起来看。
离线评测不用搞大,准备一组真实问题当回归集就行,每次换嵌入模型、调分块大小或改 prompt 都跑一遍。只看两个指标:检索有没有把正确片段召回到,生成有没有照着片段答。分开看,才能判断该改检索层还是生成层。
| 失败样本类型 | 典型表现 | 先改哪一层 |
|---|---|---|
| 长问题 | 命中片段被截断 | 分块与 prompt 预算 |
| 跨文档问题 | 只召回一半依据 | 检索条数与合并策略 |
| 表格类问题 | 片段里表格被切碎 | 分块方式 |
| 口语化提问 | 压根没召回 | 嵌入模型与查询改写 |
五、上线前的检查清单
这一节就是照着打勾,不用理解更多概念。每一项都对应一个具体动作,跑不通就说明前面某一节还没落到位,回去补那一段。
- 冷启动、首 token、满并发三种状态各跑一次,确认超时和队列行为符合预期
- 断网或向量库挂掉时,链路能给出可读错误而不是卡死
- 凭一个 request_id 能还原整次请求,检索和生成两段耗时都在
- 准备开关清单:关模型、关检索、切回关键词兜底,出事能一键降级
- 超时阈值和并发上限记在配置里,不要散落在代码各处
判断标准很简单:把上面这几条当作发布门禁,缺哪条就别上。如果你不确定自己这台机器该设多大的并发和超时,先去站内页面看当时的实测数据,再拿自己的日志对齐,不要照搬别人的数字。
下一步把你的推理服务和 Python 链路按上面的边界拆开,再拿真实问题跑一遍离线回归集;想先摸清机器性能再定阈值,可以从这里开始。→常见问题
本地 llama.cpp 跑 RAG,为什么第一批请求总是超时?
多数是模型冷加载或 KV 缓存还没热,首批请求的首 token 时间远高于稳态。做法是把建连、首 token、整体生成三段超时分开设,首 token 给足余量,并在服务启动后先跑一两次预热请求再对外接流量。用 /tools/speed-test 量出你这台机器的基线再定阈值,别照搬别人的数字。
并发一高就排队甚至拖垮服务,Python 侧该怎么限?
不要把并发交给无界线程池。用一个有界队列把请求排起来,再用信号量限制同时在跑的条数,超出的请求排队而不是直接失败。同时在前面加一层快速失败:等待超过阈值就返回明确的忙碌提示,让调用方重试,而不是让所有请求一起变慢。
检索到的东西模型不用,还是自己编,怎么查?
先把两件事分开看:检索有没有召回正确片段,以及模型有没有照着片段答。日志里记下命中 chunk id 和分数,如果压根没召回,就调分块、换嵌入模型或补关键词兜底;如果召回了没用上,就改 prompt 结构,把片段编号写清楚、要求回答必须引用编号,并降低塞入的片段数量。