尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Grok Bot 成本优化:用 durable state 降低 token 消耗

Grok Bot 成本优化:用 durable state 降低 token 消耗 我最近把一个 Grok Bot 从“每次请求都带全量历史、失败就原样重试”的写法改成基于 durable state 的有状态实现。改完之后最直观的变化不是它更聪明了而是 token 消耗明显下降服务重启之后会话还能从断点继续。这件事听起来像架构改造实际上更像一个成本控制工程先搞清楚消耗从哪里来再把该持久化的状态落库最后用指标验证省下来的部分。如果你也在维护一个 Grok Bot或者正准备把一个基于大模型 API 的 Bot 从 Demo 做成长期任务这篇内容值得看完。关键点不是某个具体模型版本而是 durable state 这套设计它能让 Bot 不重复计算、不重复请求、不无限膨胀上下文。下面按我实际执行的顺序拆开讲。1. 先算清楚Grok Bot 的消耗到底烧在哪1.1 很多消耗不是花在“回答”上而是花在“重复读历史”上无状态 Bot 最常见的实现是每次收到用户消息把整个会话历史重新拼一遍全部塞给模型。这样写起来很简单但成本会随着对话长度快速上涨。假设每轮对话平均产生 500 token为了让模型回答第 10 条消息系统可能会把前面 9 条全部带上这次请求可能已经到几千 token。到第 30 条消息时完整历史会更大。这个数字不精确但趋势很明确会话越长单位问答成本越高。如果同一个用户开多个会话切换一次就重新传一遍情况会更差。Grok 这类大模型 API 的计费通常看两块prompt tokens 和 completion tokens。prompt tokens 就是你发送给模型的输入长度包括系统提示词、历史消息、工具定义、用户问题。completion tokens 是模型生成回复消耗的 token。无状态实现里prompt tokens 往往比 completion tokens 更容易失控因为你在每一轮都重复支付历史上下文的费用。1.2 token 之外还有三类容易被忽略的消耗第一类是重复请求。很多 Bot 接入聊天平台后平台可能会因为网络超时或服务重试把同一条消息推送两次。如果代码里没有幂等处理这条消息就会被调用两次模型产生两份费用。第二类是上游超时后的重试。调用 Grok API 时如果超时常见的做法是重试。但重试时如果还把同一段完整历史原样发过去那么失败一次相当于多支付一次同样的 prompt 成本。更坏的是某些 API 返回错误之后连接实际上已经处理过请求但客户端不确认就会造成双重消耗。第三类是批量任务和定时任务。比如早上给一批用户推送摘要如果任务不是队列化的每次启动都会重复处理同一批数据。没有 durable state 的话这些任务几乎不可能做到“只处理一次”。所以所谓降低 Grok Bot 消耗不只是把 prompt 写短一点也不是让模型少说话。核心是让每一次调用都尽量有明确边界、可复用、可恢复。2. durable state 不是缓存是把无状态 Bot 改成有状态执行2.1 durable state 具体存什么durable state 可以理解成“持久化的执行状态”。它不只是缓存而是一整套让 Bot 在重启、崩溃、重复回调时依然保持一致状态的数据。一个 Grok Bot 的 durable state 至少应该包含会话状态会话 ID、用户 ID、创建时间、最后活跃时间。上下文状态最近消息、历史摘要、当前主题、用户偏好。调用状态哪些请求已经处理过、哪些请求还在处理中、哪些需要重试。缓存状态输入和输出是否被缓存、缓存命中次数、缓存有效期。用量指标每次调用的 prompt_tokens、completion_tokens、请求时间、模型版本。这些状态不放在内存里因为内存会在服务重启后丢失。durable 意味着它要落到磁盘、数据库或可靠的存储服务里。这样就算进程崩溃下一轮请求也能恢复到崩溃前的状态。2.2 关键转变从“每次完整重放”到“按需恢复”无状态实现的核心问题是每一次请求都要从零恢复完整上下文。有状态实现的核心思路是我保存了已经处理过的事情下一次只需要恢复当前决策所需的最小状态。打个比方无状态每次开会都让所有人从公司第一天的经历开始讲。有状态每天开会前记录上次结论只回顾最近几件关键事项新成员想了解早期细节时再查归档。放到 Grok Bot 里就是保存一份摘要代表较早的历史。只把最近若干条消息直接发给模型。已经回答过的问题命中缓存就不调用模型。重复平台事件通过幂等表直接返回上次结果。这就是 durable state 降低消耗的核心机制。它不牺牲全部上下文能力而是把“完整历史”转成“可恢复的高密度状态”。下面我给出一个可以直接上手的实现方案。这个方案不一定适合所有生产环境但作为第一版足够帮你把消耗降下来。3. 一版可直接上手的实现方案3.1 存储选型先 SQLite再考虑 Redis 或 PostgreSQL很多人一听 durable state就想着上 Redis。我不建议一开始就这么干。Redis 是缓存不是万能的可靠事实源。真正要保证“只处理一次”最好还是用支持事务、能持久化的数据库。我的建议是分阶段存储方案适合阶段注意点SQLite单实例、低并发、个人 Bot文件备份方便但不适合多实例并发写入PostgreSQL / MySQL多实例、需要事务和唯一约束适合做幂等表和会话表的可靠存储Redis缓存层、限流计数器适合短周期缓存不适合作为唯一事实源对象存储 / 磁盘文件保存摘要归档或长文本需要配合元数据索引查询不方便第一版直接用 SQLite 就可以。先把整个流程跑通再根据并发量决定是否迁移到 PostgreSQL 或加 Redis 缓存层。3.2 核心表结构我用三张表来支撑 durable state会话状态表、消息日志表、响应缓存表。再加一张幂等表用来拦截重复事件。CREATE TABLE conversation_state ( conversation_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, summary TEXT, token_estimate INTEGER DEFAULT 0, last_active_at TEXT NOT NULL, version INTEGER DEFAULT 1 ); CREATE TABLE message_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, input_tokens INTEGER DEFAULT 0, created_at TEXT NOT NULL ); CREATE TABLE response_cache ( cache_key TEXT PRIMARY KEY, response_text TEXT NOT NULL, model TEXT, created_at TEXT NOT NULL, hit_count INTEGER DEFAULT 1 ); CREATE TABLE processed_events ( event_id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, response_text TEXT NOT NULL, processed_at TEXT NOT NULL );不要小看这几张表。实际运行时它们承担的事情非常多conversation_state保存摘要避免每次都要读全部历史。message_log保存最近消息方便构造上下文。response_cache保存可复用的回复。processed_events实现幂等防止重复平台事件二次调用。3.3 上下文组装和缓存拦截核心代码不复杂关键是顺序。先做幂等检查再做缓存检查最后才组装上下文并调用 Grok API。def handle_message(conversation_id, user_message, event_id): # 1. 幂等检查同一事件只处理一次 if has_processed(event_id): return load_processed_response(event_id) # 2. 消息归一化用于缓存 key normalized normalize_text(user_message) cache_key make_cache_key(conversation_id, normalized) # 3. 缓存命中直接返回 cached get_cache(cache_key) if cached and not cache_expired(cache_key): return cached # 4. 从 durable state 重建上下文 messages build_context(conversation_id, max_tokens4000) # 5. 调用 Grok API response call_grok_api(messages) # 6. 保存消息和用量 append_message(conversation_id, user, user_message) append_message(conversation_id, assistant, response) save_usage(conversation_id, response) # 7. 写缓存和幂等标记 set_cache(cache_key, response) mark_processed(event_id, conversation_id, response) return response上下文组装函数可以这样写def build_context(conversation_id, max_tokens4000): state get_conversation_state(conversation_id) if not state: return [] messages [] # 先放摘要压缩早期历史 if state.get(summary): messages.append({ role: system, content: 之前的对话摘要 state[summary] }) # 只放最近的消息控制长度 recent get_recent_messages(conversation_id, limit20) for msg in recent: messages.append({ role: msg.role, content: msg.content }) # 按 token 预算裁剪避免一次性超过模型上下文 return fit_to_token_budget(messages, max_tokens)这段代码的价值在于它不是把所有历史都塞给模型而是按摘要加最近消息的方式重建上下文。历史越长省得越明显。3.4 幂等机制要单独处理幂等看起来简单但很容易被忽略。如果接入的平台能提供消息 ID优先用platform chat_id message_id作为event_id。如果拿不到消息 ID就用conversation_id content_hash 时间窗口做近似判断。注意一个边界内容哈希会误伤。用户连续发两句“在吗”会被当成重复。所以我建议幂等判断只用来拦截平台重试不用来拦截用户正常重复提问。真正的重复提问交给缓存处理缓存设一个较短 TTL 就行。4. 把方案调成“真能降消耗”的关键参数4.1 先记录用量再谈优化没有用量数据就没有优化依据。每次调用 Grok API 之后必须记录prompt_tokens、completion_tokens、cache_hit、conversation_id、event_id。这些数据要落库至少保留一周。logger.info( conversation%s cache_hit%s summary%s recent%s prompt_tokens%s completion_tokens%s, conversation_id, hit, has_summary, recent_count, response.usage.prompt_tokens, response.usage.completion_tokens )保存之后你要能回答三个问题单个会话平均每轮消耗多少 token缓存命中率是多少重复事件拦截了多少次回答不了就不要急着调参数。4.2 上下文预算和摘要策略这里给一组我常用的初始参数不是标准答案你可以按自己的场景调整参数建议初始值作用MAX_HISTORY_MESSAGES20只保留最近 20 条原始消息MAX_CONTEXT_TOKENS4000组装提示词时的 token 预算CACHE_TTL3005 分钟内重复问题直接走缓存IDEMPOTENCY_TTL3600平台重复事件 1 小时内只处理一次SUMMARY_THRESHOLD30消息超过 30 条后触发摘要压缩设置MAX_HISTORY_MESSAGES 20不是让你丢记忆而是让模型只看到最近 20 条原文。更早的内容压缩成摘要放进 system prompt。如果摘要本身都很大那还要继续压缩摘要只保留关键事实和结论。这里最容易踩的坑是摘要越写越长。如果每轮都往摘要后面追加内容过不了多久摘要会比原始历史还长。处理方式是摘要也要有 token 上限超过上限时丢弃最不重要的旧事实。说白了durable state 保存的是“有效状态”不是“全部痕迹”。4.3 缓存该开在哪些场景缓存不是越多越好。像“帮我写一首诗”“现在几点”这类问题如果缓存时间太长会变得很奇怪。我一般只对三类内容开缓存固定指令例如“设置提醒”“关闭通知”。常见问题例如“你能做什么”“你的规则是什么”。短时间重复消息例如用户在 5 分钟内反复发同一个问题。缓存 key 至少要包含会话 ID 和归一化后的文本。如果跨会话共享缓存要考虑上下文不同导致结果不匹配的问题。比如用户问“你觉得怎么样”不同会话里指代的东西不一样缓存就不应该共享。5. 从单条消息到批量场景防止状态表本身变大5.1 消息表不能只增不减durable state 本身也会占用存储。如果message_log一直 accumulate数据库会越来越大备份和查询都会变慢。我的建议是消息超过MAX_HISTORY_MESSAGES后把较早的消息摘要化然后删除对应的原始记录。保留一条摘要删掉 30 条原文状态恢复能力还在但存储成本会小很多。删除和归档要谨慎。如果你想保留完整审计日志可以把旧消息移到归档表或冷存储而不是直接清空。生产环境建议写一个定时任务每天把超过 7 天的原始消息归档一次。5.2 批量任务用队列而不是直接循环调用如果你的 Grok Bot 不止回复消息还要每天处理一批用户请求千万不要在请求处理器里直接 for 循环。这样既容易超时也无法保证失败重试。正确做法是引入任务状态表job_status: pending - processing - done / failed处理失败时任务状态回退到 pending等待下一次重试。处理中要加一个“租约时间”避免任务卡死后被多个 worker 同时处理。租约过期后其他 worker 才能重新领取任务。这套机制就是 durable state 在批量场景下的延伸。它不直接减少单次调用的 token但能避免重复处理同一批任务。重复批量任务往往是消耗的大头。5.3 多实例部署时状态写入要带锁或唯一约束当 Bot 从单实例变成多实例最怕多个实例同时处理同一个事件。解决方式有几种数据库唯一约束例如processed_events.event_id主键冲突时后写入的实例直接放弃。SELECT ... FOR UPDATE锁定会话行处理完再释放。Redis 分布式锁适合短临界区。第一版用 SQLite 时一般不会遇到这个问题。如果后面要上多实例建议优先把processed_events和conversation_state迁到 PostgreSQL利用唯一索引保证幂等。6. 排查链路消耗没降下来按这个顺序查6.1 先看现象再定位问题很多人在优化 Grok Bot 消耗时第一反应是改 prompt但实际原因可能是状态没落库、缓存没生效、重复事件没拦截。我建议按下面的排查顺序来先看日志里每次调用的prompt_tokens和completion_tokens是不是真的降了。看单会话平均 prompt tokens 的走势确认是不是长期维持在低位。看message_log的条数确认旧消息是否被压缩和清理。看response_cache.hit_count确认缓存是否真的被命中。看processed_events确认平台重复事件是否被拦截。6.2 常见现象和对应检查点现象优先查哪里可能原因prompt tokens 没降build_context 和 max_tokens摘要没生效或最近消息限制没加缓存命中率一直为 0cache key 和 TTLkey 太细或缓存存到了内存服务重启后上下文丢失conversation_state 是否落库状态还在内存没有持久化同一消息被调用多次processed_events平台的 event_id 没有传进来上下文内容错乱message_log 顺序消息没有按时间排序或摘要重复追加任务重复处理任务状态表卡住的 job 没有租约过期机制6.3 我踩过的一个典型坑有一次我以为 durable state 已经生效prompt tokens 却不降。查了半天才发现build_context函数里虽然读了summary但append_message时把新消息追加到了所有消息后面导致get_recent_messages返回的 20 条里有 15 条都是已经摘要过的旧内容。这类问题很隐蔽。现象是“上下文没有变短”本质是“摘要和原始消息没有形成替代关系”。所以每次判断 durable state 是否真正生效不只问“你有没有存状态”还要问“组装提示词时是不是真的在用紧凑状态替代完整历史”。我的经验是先跑单条任务再跑批量任务先看日志再调参数先确认状态落库再谈缓存命中率。如果只是学习SQLite 加一张会话表就够。如果要长期跑把消息清理、幂等、缓存命中率、任务租约都整理好才能保证消耗是稳定下降而不是偶尔下降。Grok Bot 本身不复杂复杂的是把外部模型接入自己系统后的边界。只要把 durable state 设计清楚成本控制就有了基础。
返回列表