公司动态

Grok Bot消耗失控?用durable state把重复记忆搬出请求

📅 2026/8/31 4:52:43
Grok Bot消耗失控?用durable state把重复记忆搬出请求
深夜十一点一位做 IM 客服机器人的朋友发来一张账单截图。他上线了一周的 Grok Bot功能没问题用户反馈也不错但 API 消耗高得离谱一天只有几百个真实会话消耗量却像跑了上万次调用。他贴出自己的请求日志我一眼就看到了问题。他的 bot 每次收到用户新消息都会把整段历史记录原封不动塞进请求里。用户聊了三十轮他就带三十轮原文用户问的是今天的新问题他还把三天前的寒暄和上周的文档一起带上。更隐蔽的是他的失败重试逻辑一遇到超时就重新调用一次前端轮询又触发一次同一个问题在几秒内被重复送给模型好几次。这不是模型太贵也不是 prompt 写得不好而是状态管理出了问题。这篇文章想说明一个判断降低 Grok Bot 这类大模型机器人的消耗最值得先做的不是调 prompt、换参数、找更便宜的模型而是引入 durable state——把机器人需要长期保存的状态从“每次请求里的重复文本”搬到“外部持久化存储”让每次 API 调用只携带必要信息。1. 大多数机器人消耗失控不是因为模型贵而是因为状态被反复重发1.1 一个危险的请求模式把整个故事每次重讲一遍很多开发者在第一次写 bot 时会走一条看起来特别自然的路径用数组保存历史消息用户每次发言后把整个数组作为 messages 参数传给模型。代码很直观测试时也能正常对话几乎不会让你察觉到问题。但一旦进入真实使用这个模式会迅速失控。原因在于大模型 API 的计费逻辑里prompt 部分是按 token 算的。你传进去的历史消息、系统指令、文档片段、工具输出都会参与计费。用户聊到第十轮你可能要传前十轮内容聊到第五十轮上下文里已经堆积了几万字。哪怕用户只是发了一句“你好”你都要为前面几万字的“背景”买单。我在实际排查过不少 bot 项目后发现真正的消耗大头往往不是模型输出而是这个反复重发的上下文。它就像你每天出门前把过去三十天的日记全部背一遍只为了回答今天“中午吃什么”这个问题。更隐蔽的是三类“隐形重复”失败重试请求超时后自动重试每次都带上同样大的上下文一次失败等于两次扣费。轮询触发前端为了等待结果反复请求同一个接口后端没有做去重模型被同一个问题打了好几遍。工具结果回流bot 调用了数据库或文档查询工具把整段查询结果又塞进下一次请求再让模型读一遍。这些场景叠加起来账单增长的速度会远超对话量的增长速度。1.2 消耗公式成本 请求次数 × 每次携带的上下文量理解消耗只需要看一个公式总成本 ≈ 请求次数 × 每次请求的 token 总量 × 单价大多数优化方案只盯着“请求次数”或者“单价”却忽略了中间那个“每次请求的 token 总量”。而 durable state 恰恰是在中间这一项上做文章。举个常见例子。100 个用户每人聊 30 轮平均每轮携带 2000 token总消耗就是100 × 30 × 2000 6,000,000 token在这 600 万 token 里真正的新增输入可能只占 10% 左右。其余 90% 都是历史消息、重复背景、已经看过的工具结果。也就是说在状态管理不做优化的情况下你花了十块钱其中九块钱都花在“让模型重新读一遍它已经读过的东西”上。从工程经验看引入 durable state 之后这类长会话机器人的消耗通常能降 50% 到 80%。这个数字不夸张因为削减的不是回答质量而是被反复重发的冗余信息。1.3 durable state 为什么是这里的第一个解药在继续往下读之前先明确一点这里说的 durable state不是某个特定框架的专有概念而是一种状态管理策略。核心思想是机器人不应该在每次请求里“临时重建”自己的记忆而应该把记忆持久化到外部存储中。会话的唯一标识、关键事实、任务进度、最近几轮原文、早期内容摘要都作为状态保存下来。每次 API 调用只需要携带一个状态引用比如 session_id一段精简后的状态摘要最近几轮原文用户当前这一条新输入这就像两个人合作处理一件事聪明同事不会每次开会都把全部背景资料重新念一遍他会在第一次沟通后做一份目录和摘要之后每次只带新增信息。注意别把“降消耗”理解成“少让模型干活”。优化的目标是在不降低回答质量的前提下去掉请求里那些重复、可复用、根本不需要再送进模型的信息。2. durable state 到底是什么把“记忆”从请求里搬到存储里2.1 状态外置请求只带变更不背全量传统的无状态请求模式看起来是这样POST /chat messages [ 第1轮用户, 第1轮助手, 第2轮用户, 第2轮助手, ... 第30轮用户, 第30轮助手, 第31轮用户 ]每一次请求都把整段对话历史重新发送一遍。模型本身并没有记忆它的“记忆”完全依赖你每次传多少内容。所以这个模式本质上是在用请求体充当数据库。引入 durable state 之后请求结构会变成POST /chat session_id s_12345 messages [ 系统消息包含已提取的事实档案, 早期摘要可选精简, 第26轮用户, 第26轮助手, ... 第30轮用户, 第30轮助手, 第31轮用户 ]系统消息和最近几轮原文来自外部存储摘要来自压缩逻辑。请求体积从一个不断膨胀的全文历史变成一个受控的、设置过上限的窗口。这个转变的意义不只是省钱。它还让成本变得可预测无论用户聊了多少轮单次请求的体积都不会无限增长。2.2 三层状态模型核心档案、短期工作区、长期摘要要设计 durable state最好不要把所有信息塞进一个字段。我更推荐拆成三层每一层有不同的来源、更新频率和放置位置。层次内容更新频率放置位置核心档案用户偏好、固定背景、业务约束、已确认的关键事实低频仅在发现新事实时更新系统消息短期工作区最近 610 轮对话原文每轮更新普通消息列表长期摘要更早对话的压缩摘要超过窗口时滚动更新系统消息或独立摘要消息这三层的分工很清楚核心档案保证机器人不会反复追问已经知道的信息。比如用户说过“我偏好简洁回复”这条就应该进入档案而不是继续留在每一轮历史里被重复读取。短期工作区保证当前对话的连贯性。最近几轮原文是理解上下文的关键不能为了省 token 全部压缩掉。长期摘要保证机器人对早期对话仍然有背景感知。摘要不需要逐句还原只需要保留主题、结论和关键约定。从这个模型可以看到durable state 不是简单的“少传点历史”而是一套有层次的记忆管理方案。2.3 和普通会话管理、向量库、Agent 状态机的区别有人会问这不就是会话管理吗我项目里已经用数据库存过聊天记录了。普通会话管理通常只是“为了展示而存储”把消息存进数据库前端要显示历史时再取出来。但调用模型时依然把全部历史一次性塞进请求。存储没有参与到请求组装里对降耗没有任何帮助。向量数据库又是另一回事。它解决的是“从大量知识片段里找到相关内容”的问题适合知识库检索。但它不是会话状态的替代品。如果你只是把整段历史扔进向量库再每次检索一堆相似片段拼进请求体积成本未必更低甚至在检索结果过多时反而更贵。Agent 状态机则更偏上层它管理多步任务执行到哪一步、下一步该做什么。真正的状态数据仍然需要一个 durable store 来承载。所以更准确的说法是durable state 是 Agent 状态机、会话机器人、自动化助手这些方案共同依赖的底座。3. 一套可落地的降耗实现从状态表到上下文组装这一节直接给出一套可以照着写的最小实现。技术选型上从小项目到生产环境都可以用“SQLite 或 Redis 一段上下文组装逻辑”起步。3.1 最小的持久化结构先用一张表保存会话状态。状态整体序列化成 JSON结构包含 summary、facts、recent 三块。CREATE TABLE sessions ( id TEXT PRIMARY KEY, state TEXT NOT NULL, updated_at INTEGER NOT NULL );对应的存取函数import sqlite3 import json import time def get_session(conn, session_id: str) - dict: row conn.execute( SELECT state FROM sessions WHERE id ?, (session_id,) ).fetchone() if row is None: return {summary: , facts: {}, recent: []} return json.loads(row[0]) def save_session(conn, session_id: str, state: dict) - None: conn.execute( INSERT OR REPLACE INTO sessions (id, state, updated_at) VALUES (?, ?, ?), (session_id, json.dumps(state, ensure_asciiFalse), int(time.time())), ) conn.commit()这里用 JSON 只是为了快速起步。如果后续状态结构越来越复杂可以考虑拆成多张表或者换成 Redis Hash 存储。第一步不要过度设计先把“状态存在外面”这个行为固定下来。3.2 请求组装系统消息只放状态摘要 最近 N 轮拿到状态后组装请求的核心原则是给每一类内容设置大小预算超出就裁剪。def build_messages(session_id: str, user_input: str) - list[dict]: state get_session(conn, session_id) # 系统消息只放事实档案给一个预算上限 facts_text json.dumps(state[facts], ensure_asciiFalse) system_content 你是用户的长期助手。以下是已经确认的事实不要重复询问\n system_content facts_text[:600] messages [{role: system, content: system_content}] # 早期摘要作为背景同样设上限 if state[summary]: messages.append({ role: system, content: 早期对话摘要 state[summary][:800], }) # 只带最近 N 轮原文一般取 610 轮 messages.extend(state[recent][-10:]) messages.append({role: user, content: user_input}) return messages, state这里有两个很容易被忽略的细节。第一事实档案和早期摘要是分开的不要把两者合并成一大段。因为事实档案需要模型“严格记住”摘要只是“背景参考”它们的更新频率不同混在一起之后很难做增量更新。第二所有内容都要设上限。事实档案 600 字符、摘要 800 字符、最近 10 轮这个数字可以根据你的业务调整但绝不能不加限制。否则状态做了持久化请求体积还是会因为某个字段异常而膨胀。3.3 响应后更新提取事实、裁剪窗口、滚动摘要请求返回之后不能只把回复发给用户还需要更新状态。这一步是 durable state 真正工作的时刻。def update_state(state: dict, user_text: str, assistant_text: str) - dict: state[recent].append({role: user, content: user_text}) state[recent].append({role: assistant, content: assistant_text}) if len(state[recent]) 12: old_turns state[recent][:6] state[summary] merge_into_summary(state[summary], old_turns) state[recent] state[recent][-6:] maybe_extract_facts(state, user_text, assistant_text) return statemerge_into_summary有两种实现方式。一种是用规则如果对话只涉及几个固定业务意图可以按意图模板提取关键字段合并进摘要。这种方式零额外调用但只适用于业务边界清晰的场景。另一种是用模型做摘要把早期几轮文本交给模型让它输出 23 句摘要。这种方式更通用但会产生额外的 token 消耗。我的建议是控制频率比如每积累 6 轮才做一次摘要不要每轮都触发。maybe_extract_facts同理。可以在用户明确表达偏好、做出决定或提供长期信息时用一次小规模调用提取事实写入facts。如果业务简单也可以用关键词规则判断。建议把“摘要合并”和“事实提取”放在低频或低峰期触发不要用户每说一句话就额外调一次模型。否则省下的上下文 token会被新的调用次数找回去。3.4 缓存和去重有些请求根本不该发出状态持久化能降低“每次请求的体积”但还有一类消耗是“同一个问题被反复调用”。对于这部分缓存和去重是更直接的手段。一个简单但实用的做法对用户 ID 和规范化后的问题做哈希短时间命中缓存就直接返回。import hashlib def normalized(question: str) - str: return .join(question.lower().split()) def answer_with_cache(user_id: str, question: str) - str: key fgrokanswer:{user_id}:{hashlib.md5(normalized(question).encode()).hexdigest()} cached cache.get(key) if cached is not None: return cached answer call_grok(question) cache.set(key, answer, ttl3600) return answer注意几点规范化很重要。同一个问题可能因为大小写、多余空格、全半角符号而生成不同哈希。先统一格式再来判断是否重复。不要缓存所有问题。天气、报价、实时状态这类动态信息不适合缓存或者缓存 TTL 要设得很短。适合缓存的是常见 FAQ、产品介绍、固定流程说明。缓存的粒度最好拆到用户维度。不同用户问同一个问题答案可能因用户历史而不同混用缓存会出错。4. 进阶任务级 durable state把 Agent 的中间结果变成可复用资产如果你的 Grok Bot 不只是聊天还会调用工具、处理文档、执行多步任务那么 durable state 能发挥的价值更大。聊天场景省的是上下文体积任务场景省的是重复执行整条流水线。4.1 工具调用结果缓存下来不重复烧钱很多 bot 在对话过程中会调用数据库查询、文档检索、固定接口或计算脚本。这些工具的输出结果常常被直接塞进上下文让模型基于结果继续回答。但工具结果往往在短时间内是稳定的。同一个查询五分钟内再问一次答案大概率一样。如果每次对话都重新执行工具再把结果传一遍消耗会成倍增加。def call_tool_with_cache(tool_name: str, params: dict) - str: key ftool:{tool_name}:{json.dumps(params, sort_keysTrue)} hit cache.get(key) if hit is not None: return hit result run_tool(tool_name, params) cache.set(key, result, ttl300) # 5 分钟内不重复执行工具 return result这里的关键是给不同工具配置不同的 TTL。稳定数据可以缓存 10 分钟到 1 小时实时数据可能只能缓存 30 秒。不能一刀切。缓存工具结果还有一个额外收益即使模型需要重新生成回答它读取的工具结果是缓存里的同一份数据前后一致性也更好。4.2 任务状态机不做完不重跑多步任务最容易踩的坑是用户说一句“继续”或“然后再处理一下”bot 就把整个任务从头再跑一遍。重复调用模型重复执行工具最后结果还未必稳定。更好的做法是把任务进度也纳入 durable state。用一个字段记录当前任务阶段{ task: { name: 周报生成, stage: 已完成数据汇总待生成结论, artifacts: { sale_summary: Q2 销售额 120 万环比增长 8% } } }下次用户说“继续”时bot 先读取状态发现自己已经完成数据汇总就直接从“生成结论”这一步继续而不是重新跑数据查询。从工程经验看任务状态机的价值不只是省 token更是让整个任务流程可控。中间结果被持久化后即使进程重启、请求超时任务也可以从断点恢复而不是全部重来。4.3 失败重试时的幂等策略重试是 Bot 消耗的隐形炸弹。我见过不少项目的重试逻辑是这样写的请求失败sleep 一下再调一次。如果第一次请求其实已经成功只是响应超时第二次调用就会重复消耗一次还可能重复发送消息。解决思路是在 durable state 里加一个幂等标记。每一次用户请求生成一个 request_id。调用模型之前先检查状态里是否已经存在这个 request_id 对应的结果。如果存在直接返回已有结果如果不存在才发起调用并在成功后把结果写入状态。def handle_message(session_id, request_id, user_input): state get_session(conn, session_id) if request_id in state.get(processed_requests, {}): return state[processed_requests][request_id] messages, state build_messages(session_id, user_input) result call_grok_with_timeout(messages) state[processed_requests][request_id] result save_session(conn, session_id, state) return result这样即使前端重试三次只有第一次会真正调用模型其余两次都会命中状态。重试是 Bot 消耗的隐形炸弹。给每次请求一个幂等键重试前先读状态里是否已经存在这次调用的结果。5. 上线前必须建立的观测和排查链路状态管理做得再好如果没有观测你仍然不知道消耗是从哪里漏掉的。所以 durable state 落地之后下一步必须是建立一套消耗观测体系。5.1 先记录 usage再谈优化市面上多数大模型 API 都会在响应里返回 usage 字段包含 prompt_tokens、completion_tokens 等信息。很多 bot 项目根本不看这个字段也没有任何日志记录。我的建议是每笔调用都记一条日志至少包含时间、会话 ID、模型名、输入 token、输出 token、总 token、是否命中缓存、是否重试。{ ts: 2025-06-01T12:00:00Z, session: s_12345, model: grok, prompt_tokens: 320, completion_tokens: 80, total_tokens: 400, cache: false, retry: false }这些日志存到一张表或一个日志文件里每天汇总一次就能看到三个核心指标单会话平均 token 消耗趋势。请求次数和对话轮数的比例用于发现重复调用。缓存命中率用于评估去重逻辑是否生效。如果没有 usage 日志后面所有的优化都是凭感觉。这不是夸张而是我排查过大量 bot 项目后的真实结论绝大多数消耗异常靠日志一查就能定位根本不需要猜。5.2 五个位置的排查顺序当消耗异常升高时按下面的顺序排查比随机尝试更有效。排查位置看什么常见解法usage 日志是否真的有异常增长增长在 prompt 还是 completion先确认问题存在再动手请求体积单次请求 messages 长度是否随轮数无限增长限制最近 N 轮 摘要窗口重复调用同一用户同一问题是否在短时间内被多次请求规范化问题 结果缓存重试逻辑超时或失败后是否重复调用第一次可能已成功的请求幂等键 状态结果复用工具结果工具输出是否反复出现在上下文里工具结果缓存 只放必要字段这个顺序的核心逻辑是先看现象再看输入再看环境最后看工具边界。不要一上来就怀疑模型贵或者 API 有问题。5.3 一张降耗检查表如果你想快速评估自己的 bot 是否有改进空间可以用下面这张表。检查项通过标准请求体积上限每次请求有明确的上下文 token 预算超限会裁剪事实去重同一事实不会同时出现在摘要和最近消息里窗口裁剪最近消息窗口有长度上限早期内容已滚动进摘要工具结果缓存静态工具结果带 TTL 缓存不会每次重查失败重试幂等重试不会重复调用模型也不会重复发送消息用量日志每笔调用都有 usage 记录可按会话和天汇总如果这六项里超过三项不满足说明消耗还有很大的优化空间。6. 适用边界和使用建议6.1 适合什么不适合什么durable state 不是银弹它有明确的适用边界。适合的场景长会话客服机器人用户可能一周都在同一个会话里提问历史积累快状态管理收益显著。个人助理类 bot需要长期记住用户偏好、项目和日程。多步 Agent 任务任务中间结果值得保存失败可以从断点恢复。高频重复咨询问题模式固定缓存去重能挡住大量重复消耗。不适合的场景一次性问答用户问完就走没有上下文需要保存引入状态存储反而增加复杂度。极短会话两三轮就结束的对话持久化开销可能大于收益。要求严格无状态的服务某些服务设计上就不能依赖客户端传 session 来恢复上下文。在做决策时不要因为这篇文章讲了 durable state 有多好就把它硬塞进所有场景。先判断你的 bot 是否真的存在“历史积累导致请求膨胀”的问题再决定是否引入。6.2 什么时候该用向量检索而不是持久状态有一类问题容易被误判为会话状态问题其实是知识检索问题。比如用户问“你们产品的退款政策是什么”bot 如果每次都把整份说明书带进上下文那确实是在浪费 token。但这种场景的合理解法可能是先做一次精确检索只把相关的几段政策文本带回上下文而不是把整份说明书持久化到状态里。区分标准很简单如果是“同一个用户在不同轮次中持续依赖的偏好、进度、结论”用 durable state。如果是一个庞大的公共知识库被不同用户反复查询用向量检索加 RAG让每次请求只携带检索到的相关片段。实际项目里两者经常配合使用。状态负责“记住用户”检索负责“找到知识”它们不是二选一的关系。6.3 如果一个月后回看最该先做哪一件事给第一次接触 durable state 的读者一个建议不要试图一天之内把所有机制全部落地。正确顺序是先加用量日志。让每一笔调用都能被统计建立消耗基线。再限制上下文窗口。引入最近 N 轮加摘要的机制让请求体积不再无限增长。然后加缓存和去重。把重复调用挡住。最后再考虑事实提取、任务状态机和幂等重试。这个顺序的核心逻辑是先让问题可见再用最小改动止血最后才逐步完善。我见过太多项目一上来就设计一套复杂的 Agent 状态机结果连基本用量日志都没有。状态机本身没有错但如果没有观测打底后面出了问题根本不知道是哪一步造成的。回到开头那个朋友的案例。他后来做的事情其实不多把历史消息改成最近 8 轮加滚动摘要给工具结果加缓存再给重试加幂等键。一周之后他的账单消耗降到了原来的十分之三而用户几乎感觉不到变化。这件事真正值得记录的不是“省了多少钱”而是他换了一种思考方式不再把大模型 API 当成一台每次都要从头听你讲完所有背景的无状态机器而是把它当成一个真正持续工作的协作者。你只需要在每次对话开始时递给它一份更新过的档案然后告诉它这次新发生了什么。这就是 durable state 的本质。它不是一个缓存技巧也不是一句“少传点历史”就能代表的做法而是把机器人从“每次从零开始”变成“带着状态继续工作”的架构思维。如果你的 Grok Bot 现在还在每次请求里全量重放历史今晚最值得做的一件事不是去调什么高级参数而是先给它的记忆找一个持久化的家。