
上个月调优公司AI客服系统的时候我被一个很常见的现象折腾得够呛用户明明只问了一句我的订单OD123456到哪里了大模型却经常回出一段完整的话术什么您好您查询的订单目前正在运输途中预计8月30号送达请您耐心等待哦。这句话看着没问题但token成本、响应时长、下游解析难度全都来了。月底账单拉出来token费用翻了一倍。后来我翻了对话日志发现一个扎心的事实这类答案里的核心信息其实早就在Elasticsearch里躺着。订单状态、预计送达时间、运单号、仓库城市全是结构化字段。模型干的事情不过是用一段废话把这些字段重新翻译了一遍。于是我开始琢磨能不能让大模型少写点字把这些事实型信息以最精简的方式直接吐出来我内部做了个方案代号就叫Elastic-caveman。名字有点搞怪思路却很直接像原始人打猎一样目标在哪长矛就扔哪不搞花哨的绕路。核心做法是让Elasticsearch承担更多查题和计算工作AI只负责真正需要语言理解和生成的部分。实测下来AI响应Token平均降低了64%Elasticsearch那些看家的全文检索、聚合分析、精确过滤能力一个都没少用。这篇就把完整思路、代码设计和踩坑记录拆开讲给正在做RAG、AI Agent或者知识库问答的朋友做个参考。1. 内容整体设计与思路拆解1.1 响应Token为什么是隐形成本很多人做AI应用会把注意力放在Prompt设计上觉得只要把上下文塞得足够多模型就能回答得足够好。其实真正常占成本的是两部分一是检索后拼接进Prompt的上下文二是模型生成出来的Completion文本。前者容易被看到后者更容易被忽略偏偏它最不受控。一个很典型的场景用户问这个订单为什么还没发货系统把订单文档、商品描述、物流记录、售后规则一起拼进Prompt这部分可能已经吃掉900个Token。然后大模型为了显得专业又会回复非常抱歉给您带来不便我们已经注意到您的订单暂未发货可能是由于供应商补货延迟我们将在48小时内为您处理。这不经意间又消耗200Token。一来一回一次请求就超过1100Token。如果每天几千次调用成本一下子就上去了。更麻烦的是大模型输出的自然语言并不稳定。你想让下游系统自动处理这个回答还得让模型输出一段结构化结果于是往往又得在Prompt里加JSON约束加few-shot示例结果Token不降反升。所以真正要做的不是提示模型说短一点而是从架构上减少大模型参与的必要性。1.2 Elastic-caveman把生成改成查询Elastic-caveman的核心设计原则可以浓缩成一句话能用Elasticsearch查出来的绝不交给LLM编。这句话听起来简单落地时要分两步。第一步把用户的问答意图分成两类事实检索型和生成理解型。事实检索型包括订单到哪了这个商品还有库存吗昨天新增了多少退款单近七天销量最高的品类是什么这些本质上是一次数据库查询或聚合生成理解型包括帮我总结一下这个客户投诉的要点分析一下这周退款率升高的原因这些才需要大模型去做归纳、推理和语言组织。第二步为事实检索型问题设计一条快速路径。中间层先解析用户问题把它转成一个结构化的Elasticsearch查询直接查索引、算聚合然后用一套预置的话术模板把结果拼装成答案。整个过程中大模型根本不参与也就没有任何Completion Token产生。只有当快速路径无法覆盖时请求才会降级到LLM。这个设计看起来有点原始所以我当时给它起了caveman这个名字。但它解决的问题很现代Token不只是钱的问题还关系响应速度。Elasticsearch查询通常是几十毫秒大模型生成往往要一两秒甚至更久。能走快速路径的问题全部走快速路径整体延迟也能直观降下来。1.3 为什么仍然保留Elasticsearch的最佳功能我见过不少RAG项目把Elasticsearch当成一个纯粹的向量数据库用只做kNN向量检索反而把最核心的BM25全文检索、Filter过滤、聚合分析、排序机制都扔到一边。这是很可惜的事情。Elasticsearch的优势恰恰在于它能同时处理模糊语义和精确条件。比如用户问有没有便宜的无线耳机如果只用向量检索可能召回一批语义相似的图片或文本描述但加一个range过滤条件把价格上限、库存状态、品牌筛选直接放进Query DSL里返回结果就会精准得多。也就是说Elasticsearch完全可以承担一部分理解过滤的工作而不只是当文档库。Elastic-caveman在设计上刻意保留了这些能力。中间层生成的查询会灵活组合multi_match处理口语化关键词用term和range处理结构化条件用aggs完成统计类问题。这样做的收益是大量原本需要塞给LLM的上下文内容可以预先在ES里被过滤、聚合、裁剪到很小LLM拿到的是加工后的高密度信息而不是一堆原始文档。2. 核心细节解析与实操要点2.1 先拆响应Token再动手压缩从实际经验来看一个普通RAG问答请求的Token消耗通常由四部分组成系统Prompt、检索上下文、用户Query、模型Completion。系统Prompt往往相对固定用户Query短则10个、长则50个Token可压缩空间不大。真正能压的空间在检索上下文和模型Completion这两个部分。检索上下文最大的问题是无脑拼接。早期版本里我经常把命中的文档全文都塞进Prompt不管字段是不是跟用户问题相关。比如用户只问产品价格我却把商品详情、描述、库存、评价全塞了进去。Prompt变长模型还容易抓到无关信息回答质量也跟着下降。所以第一步要做的是在进入LLM之前把检索结果加工成只包含所需字段的元数据。模型Completion的问题则是过度自然语言化。大模型默认会按对话助手的方式说话让人感觉亲切但代价是一堆寒暄词、连接词和重复解释。后来我们用结构化输出加模板兜底才把这部分压下来。2.2 板斧一把生成型回答改成查题型回答我们第一个落地的优化是让中间层先把用户问题识别成查题。举几个例子OD123456到哪了 →term查询订单状态还有多少库存 →range过滤后sum聚合最近7天退款原因TOP3 →date_histogramterms聚合这一类问题有一个共同特点答案是确定的事实不需要大模型自由发挥。我们直接在中间层写了一个简单的意图规则解析器用正则加词典判断是否命中订单、库存、统计、FAQ等常见模板。命中之后直接执行Elasticsearch查询再按模板把结果拼好。比如订单状态的模板可能是订单 {order_id} 当前状态{status} 如为配送中预计 {eta} 送达。这段文本生成后实际Token可能只有10到20个。相比之前让LLM把相同信息扩展成两句话省下来的Token非常可观。有人可能会担心规则解析器过于脆弱。确实规则只能覆盖常见表达。所以我们没有用规则去替换所有LLM能力只是用来处理那些句式相对固定、关键词明确的高频问题。覆盖面不够的时候规则解析器返回None请求自动降级到LLM。这样既有稳定性又保证了扩展性。2.3 板斧二让Elasticsearch先把文档加工成统计结果有些问题看起来必须让LLM总结其实Elasticsearch自己也做得了。比如用户问这周退款订单里主要原因是什么传统做法是把几十条订单记录全部塞给LLM让它概括。这种做法又慢又费Token。换成Elastic-caveman后中间层会把这类问题映射成一个聚合查询按退款原因字段做terms聚合统计每个原因的出现次数。Elasticsearch返回的数据已经是一张分布表比如物流延误12次、商品破损5次、其他2次。然后中间层再把这张分布表套进模板返回或者只把这张表塞给LLM让它生成一句简短总结。第二种方式是我个人更推荐的。因为当用户问的是主要原因是什么时LLM真正需要的是已经汇总好的分布数据而不是原始明细。原始明细可能占几百个Token汇总结果可能只有几十个Token信息密度完全不同。这样既尊重了LLM的语言组织能力又大幅削减了Prompt Token。用Elasticsearch的聚合能力替代LLM的一部分总结工作是这个方案里最巧妙的一点。2.4 板斧三限制LLM的自由发挥不是所有问题都能被规则和聚合覆盖。当必须调用LLM时我们仍然要做一件事限制它的输出格式。早期我直接在System Prompt里写请用一句话简洁回答模型偶尔能做到更多的时候还是会啰嗦。后来改成function calling方式让模型输出一个结构体情况立刻好转。比如设计一个工具函数answer_question(query, context)要求模型只返回一个JSON对象包含summary和source_count等字段。模型就知道没有必要写亲爱的用户这类废话。同时把temperature调到0.2把max_tokens显式设成一个较小的值都能有效压缩Completion Token。这里要注意max_tokens并不是越小越好。设得太小会导致回答被截断反而需要二次请求或重试。我们用了一段观测数据来调整大多数事实型回答的Completion Token在80-150之间所以把max_tokens设为200并配合输出格式约束既不截断又能兜底。2.5 64%这个数字是怎么算出来的很多人看到标题会问这个64%是随便写的吗不是我们测了一段时间按所有AI请求的Token求平均算出来的。采样数据大概是这样优化前一次常规问答请求的平均Token消耗是960其中Prompt部分约720Completion部分约240。优化后大部分请求走了快速路径平均Total Token降到345其中Prompt部分约270Completion部分约75。两者相减节省615Token615除以960约等于64%。指标优化前优化后变化平均Prompt Token720270降低62.5%平均Completion Token24075降低68.7%平均Total Token960345降低64.1%单次平均响应时间约1.8s约0.35s降低80%这个数据基于我们内部的客服问答场景不同业务差异会很大。如果你的场景大量是开放式、创作式对话那压缩空间会小一些如果类似我们这种订单查询、FAQ、数据分析类场景压缩60%以上并不难做到。3. 实操过程与核心环节实现3.1 环境准备先搭一个Elasticsearch环境做实验。我习惯用Docker快速起一个单节点实例。为了本地测试方便这里暂时关闭安全认证生产环境请务必开启。docker run -d \ --name elastic-caveman \ -p 9200:9200 \ -e discovery.typesingle-node \ -e xpack.security.enabledfalse \ docker.elastic.co/elasticsearch/elasticsearch:8.13.4启动后确认服务正常curl http://localhost:9200看到带cluster_name的JSON返回就算成功。Python这边需要两个库一个连ES一个用来调用LLM接口pip install elasticsearch openai如果是用OpenAI兼容网关比如各种国内LLM服务或自建网关只需要改base_url和api_key代码结构基本不变。3.2 设计索引和MappingElastic-caveman能不能跑得好索引设计占了很大比重。我以一个订单中心为例创建order_center索引。下面是一个简化版mappingPUT /order_center { mappings: { properties: { order_id: { type: keyword }, user_id: { type: keyword }, status: { type: keyword }, category: { type: keyword }, total_amount: { type: float }, eta: { type: date, format: yyyy-MM-dd }, created_at: { type: date }, description: { type: text } } } }这里有几个点要说明status、order_id、user_id、category都用keyword而不是text因为后续会对它们做精确过滤和聚合keyword类型在term查询和aggs里更高效也不会被分词搞出意想不到的结果。description用text是为了支持全文检索。total_amount用float方便做范围过滤和统计。3.3 写入示例数据用ES的Bulk接口写几条演示数据方式很多我用Python举个简单例子from elasticsearch import Elasticsearch from elasticsearch.helpers import bulk es Elasticsearch(http://localhost:9200) docs [ {_index: order_center, _id: 1, _source: { order_id: OD123456, user_id: U1001, status: shipping, category: electronics, total_amount: 599.0, eta: 2025-05-18, created_at: 2025-05-15, description: 无线降噪耳机用户反馈物流速度偏慢 }}, {_index: order_center, _id: 2, _source: { order_id: OD123461, user_id: U1002, status: delivered, category: home, total_amount: 89.0, eta: 2025-05-14, created_at: 2025-05-12, description: 家用收纳箱已签收 }} ] bulk(es, docs) es.indices.refresh(indexorder_center)这里字段故意设计得有相关性方便后面演示物流慢这类需要语义理解的场景。3.4 实现ElasticCaveman中间层现在写一个核心类它负责把用户问题分成两条路径。import json import re from elasticsearch import Elasticsearch class ElasticCaveman: def __init__(self, es_client): self.es es_client def try_direct_answer(self, query: str): order_match re.search(rOD\d{6}, query) if order_match: order_id order_match.group(0) result self.fetch_order_status(order_id) if result: return self.format_order_answer(result) return None def fetch_order_status(self, order_id): resp self.es.search( indexorder_center, query{term: {order_id: order_id}} ) hits resp[hits][hits] if not hits: return None src hits[0][_source] return { order_id: src[order_id], status: src[status], eta: src.get(eta) } def format_order_answer(self, data): if data[status] shipping: return f订单 {data[order_id]} 运输中预计 {data[eta]} 送达。 if data[status] delivered: return f订单 {data[order_id]} 已送达。 return f订单 {data[order_id]} 状态{data[status]}。这个类做的事情很简单从问题里抽取订单号查ES拿到结构化数据拼出一句很干的话。如果你愿意还可以加更多模板库存查询、聚合统计、FAQ精确匹配。关键是try_direct_answer返回None时才走LLM路径。3.5 用LLM的function calling兜底当try_direct_answer不能命中时我们需要让LLM自己决定要不要查Elasticsearch。与其把所有文档一股脑塞进Prompt不如给它一个search_orders工具让模型按需调用。工具定义大概长这样{ type: function, function: { name: search_orders, description: 查询订单状态、物流、金额等信息根据字段过滤订单, parameters: { type: object, properties: { order_id: {type: string}, status: {type: string}, user_id: {type: string} }, required: [] } } }调用LLM时把这个工具传进去。模型如果觉得需要查订单会返回一个函数调用请求而不是直接生成一篇小作文。我们解析到调用后去ES查数据把结构化结果返回给模型再让它基于这份很紧凑的数据做最终回答。这样Prompt里的文档数量就会非常少Token自然降下来。同时对模型返回的function_call参数做白名单校验避免模型传一些奇怪的字段导致ES查询报错。这一段实操是Elastic-caveman最核心的补丁逻辑能用规则完成的先用规则规则覆盖不到的交给模型判断要不要调用工具模型始终处于被需要时才生成的位置。4. 常见问题与排查技巧实录4.1 质量下降过度压缩让答案太干第一个遇到的坑是部分用户反馈回答变得很生硬。查了之后发现问题出在把一些本应该解释的关系型问题也走了快速路径。比如用户问为什么耳机还没发货我们只抽取到订单号直接返回了订单OD123456运输中预计5月18日送达却没有解释为什么。解决办法是给快速路径加一道必要性判断。规则不只做关键词匹配还要判断问题里是否含有为什么怎么办建议这类需要原因解释的意图词。一旦命中就放弃快速路径降级到LLM。同时在ES结果里增加一个简单的置信度判断比如看查询是否命中足够精确的字段如果命中分数太低或者没有命中也走LLM。4.2 Token不减反增的根因有段时间我们改了Prompt结构加了JSON输出约束和几个few-shot用例结果Token不降反增。原因很明显结构化输出和例子本身也是Token。如果这些约束占用了80Token而原本的开放性输出只省了60Token整体就亏了。后来我把few-shot从Prompt里移到了工具定义里因为工具描述比Prompt里的长文本更紧凑而且模型对工具逻辑的遵循度反而更高。其次我对输出结果做了同级校验凡是能用模板拼的坚决不走JSON模式只有真正需要模型生成字段时才用结构化约束。压缩Token不能靠加东西逼模型改变而是要从流程上减少模型要处理的东西。4.3 如何准确观测Token用量不看数据就做优化很容易自嗨。我们在网关层对所有LLM请求做了日志记录至少记录以下字段请求时间、用户问题、是否走快速路径、prompt_tokens、completion_tokens、ES查询命中数。有了这些数据在Kibana里建一个Dashboard直接对比优化前后的平均Token才能确认效果。如果你不想接Kibana写个简单的Excel分析也够用import json from collections import defaultdict with open(logs.jsonl, r) as f: stats defaultdict(list) for line in f: entry json.loads(line) if entry.get(fast_path): stats[fast].append(entry[total_tokens]) else: stats[llm].append(entry[total_tokens]) for path, tokens in stats.items(): print(path, sum(tokens) / len(tokens), len(tokens))这种脚本虽然朴素却是调优过程中最有力的证据。4.4 避坑心得索引字段能用keyword就别用text。text字段做term过滤时经常命中不到聚合也会多走分词逻辑白白耗资源。多字段返回时尽量用_source过滤。比如只要订单状态和ETA就_source: [order_id, status, eta]减少ES网络传输和中间层内存占用。用户口语里经常有错别字multi_match加一点fuzziness能提升召回但也别设太大否则相关度会乱。LLM调用要加缓存。同一个问题、同一个用户短时间内很可能重复问。直接用查询结果的hash作为缓存key命中后连ES都省了。监控响应时间也很重要。Token下降之后延迟大概率会跟着下降这是最直观的收益比单纯省钱更容易向团队汇报。整套Elastic-caveman方案走到这里已经从一个省Token技巧变成了一个AI搜索架构的思考方式。我个人最大的感受是做AI应用不是什么都交给大模型才算AI合理的架构应该让数据库、检索引擎、规则引擎和大模型各司其职。Elasticsearch本来就是个很能打的查询和聚合引擎不该只当文件柜用。把事实查询、统计汇总这类工作从LLM手里拿回来Token省了速度也快了模型还能把精力留到真正需要它的地方。最后再分享一个小技巧别一开始就追求彻底替换LLM。先挑三类高频问题比如订单状态、库存数量、FAQ精确答案把它们做成快速路径观测一周Token和延迟确认稳定后再逐步扩大覆盖面。这样既有立竿见影的数据汇报又不会因为一次性改动太大而翻车。