南京那场 Elastic Meetup 上,数新智能讲的 Elasticsearch AI Agents 长效记忆很落地。散场后照着自己复现,卡在模型通道:长会话反复抽取、总结、研判,Token 掉得飞快。后来把 Agent 的模型出口统一交给 TaoToken,落地页 https://taotoken.net/?utm_source=taotoken_aicg_blog_end ,ES 侧的检索、打分、记忆结构一行没改。
这篇就写这条线。Agent 还是那个 Agent,MCP Server 还是那个 Server,embedding 还是自己算,变的只有一处:模型认证不再分散到各家官方入口,而是统一从 TaoToken 拿一把 Key,Base URL 填https://taotoken.net/api。这样做的好处很实际——你在本地跑长会话调试,抽记忆、做摘要、判断冲突这几步都要调模型,如果每个环节挂不同供应商,额度、限流、模型名、账单日期全都要记,写到一半没额度是最影响节奏的。
先把边界说清楚:本文不改 ES 的检索结构,也不改你的记忆打分公式。你要动的只有 Agent 和 MCP Server 里模型客户端的那几行配置。索引 mapping、function_score、时间衰减参数,全部保持你原来的样子。
1. 南京 Meetup 散场后,我把 Agent 的“失忆”复现了一遍
1.1 数新智能那场议题解决的是“记得太多太旧”,不是“搜不到”
现场不少人是冲着检索去的,听完发现议题真正在讲的是记忆的时效性。Agent 的长效记忆如果只做语义召回,会出现一种很别扭的状态:你说的话它全都“记得”,记录确实捞回来了,可捞回来的是三个月前的结论。用户早改了偏好,Agent 还按旧文档回答,看起来像失忆,其实是记忆太整齐地堆在一起、没有新旧之分。
他们的解法不复杂:每条记忆带上时间戳和重要度,检索时把语义相似度和时间新鲜度叠在一起算分。语义负责“找得到”,时间衰减负责“别翻老黄历”,重要度负责“关键决策不能被时间冲淡”。这套东西落在 ES 上,就是一份带时间字段的 mapping 加一个function_score。
1.2 现场举的例子,向量相似度确实帮不上时间
我印象最深的是他们提到的一个场景:同一个用户先后两次问到部署方式,第一次说“先上单机验证”,第二次说“直接上集群”。两句话的向量距离非常近,语义上几乎是同一件事,纯向量召回会把两条一起返回,甚至因为第一条更长、描述更细,排得还更靠前。Agent 拿到这个上下文,输出就会前后打架。
时间衰减在这里起的作用是排序,而不是过滤。老记忆不会被删掉,只是在打分上被压下去;如果新记忆里明确否定了旧结论,旧的那条会沉到候选集底部。这样 Agent 的上下文里既有当前结论,也保留了变化轨迹。
1.3 复现时真正卡住的,是模型出入口
真正动手才发现,ES 那部分是最省心的。写出 mapping、调好衰减参数、跑通_search,一下午就够。耗时间的是上面那层:Agent 每轮要做抽取、总结、冲突研判,每一步都要调模型。我在本地用三家不同的 Key 拼着跑,结果就是模型名对不上、限流时间错开、日志里分不清哪次调用是谁花的。
统一模型出口之后,剩下的事情就变成纯工程问题。Key 从 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 创建,代码里只认一个 Base URL 和一把 Key,调试时看日志就能定位是检索层的问题还是模型层的问题。
2. ES 侧的记忆结构,先定死再谈模型
2.1 agent_memory 索引要留哪些字段
先给一份可以直接用的 mapping。字段不多,但每个都有用:session_id用来隔离会话,event_time是时间衰减的依据,importance是人工或模型给的权重,embedding放语义向量,content留原文方便回看。
PUT /agent_memory { "mappings": { "properties": { "session_id": { "type": "keyword" }, "memory_type": { "type": "keyword" }, "content": { "type": "text" }, "embedding": { "type": "dense_vector", "dims": 1024 }, "event_time": { "type": "date" }, "importance": { "type": "half_float" } } } }dims必须和你实际用的 embedding 模型输出维度一致,这个值填错了索引建不起来,或者写入时直接报维度不匹配。别照抄 1024,先确认自己那套向量化流程产出的长度。
2.2 function_score 把时间衰减和重要度叠起来
语义召回负责拿候选集,重排交给function_score。下面这段是重排阶段的查询,gauss让 14 天前的记忆分数衰减到一半,两天以内的不做惩罚,importance用field_value_factor直接乘进去。
GET /agent_memory/_search { "query": { "function_score": { "query": { "bool": { "must": [ { "match": { "content": "部署方式偏好" } } ], "filter": [ { "term": { "session_id": "s-20261107-nanjing" } } ] } }, "functions": [ { "gauss": { "event_time": { "origin": "now", "scale": "14d", "offset": "2d", "decay": 0.5 } } }, { "field_value_factor": { "field": "importance", "factor": 1.2, "missing": 0.5 } } ], "score_mode": "sum", "boost_mode": "multiply" } } }两个参数的调整经验:scale别设得太小,否则一周前的记忆直接沉底,Agent 会显得“只记得刚才说的话”;offset是给正常对话留的缓冲,同一场会话里说的话不该互相惩罚。这两个值跟你的业务节奏有关,没有通用最优解,跑几轮真实会话再回头调。
2.3 换模型通道不碰这套结构
把上面这段存成模板,后面无论你用哪个模型做 embedding、哪个模型做摘要,索引和查询都原样不动。模型只负责生成文本和判断冲突,它不参与打分。这个分工划清楚以后,模型换了、Key 换了、通道换了,ES 那侧一行都不用改,回归测试也省了。
3. 创建 Key 与填 Base URL:只认 https://taotoken.net/api
3.1 到落地页注册并创建 API Key
需要准备的东西就三样:一个能访问的 ES 实例、一把模型 API Key、一份模型 ID。Key 的获取路径是打开 TaoToken 完成注册,进控制台创建 API Key。创建时通常会让你起个名字,建议按用途起,比如es-memory-agent-dev、es-memory-agent-prod,这样后面看用量能一眼分清是哪个项目在花。
Key 只在创建时完整显示一次,复制走以后页面上就只剩前后几位。粘贴到配置文件之前,先确认没带上首尾空格,很多 401 就是这么来的。
3.2 Base URL 与模型 ID 的填写规则
这里是最容易搞混的地方,单独说清楚:给人点的落地页是https://taotoken.net/?utm_source=taotoken_aicg_blog_end,填进代码里的 Base URL 是https://taotoken.net/api。两者不是同一个地址,别互相顶替。Base URL 末尾不要加/v1,SDK 一般会自己拼路径,你手动加一层就会变成/v1/v1/chat/completions,报 404。
模型 ID 不要去网上抄别人的示例,每个通道开放的模型列表会变,以模型广场当时的列表为准。复制过来直接填,不要自己加日期后缀,也不要凭印象写。
3.3 环境变量别写死在代码里
本地调试用环境变量最省事,也避免把 Key 提交进仓库。
export TAOTOKEN_API_KEY=YOUR_API_KEY export TAOTOKEN_BASE_URL=https://taotoken.net/api # 模型 ID 以模型广场当时的列表为准,复制过来直接填 export TAOTOKEN_MODEL_ID=YOUR_MODEL_ID如果你的项目里已经有OPENAI_API_KEY这类变量,别直接覆盖,另起一个名字。Agent 代码常常同时连多个服务,变量名撞车会让排查变成猜谜。
4. MCP Server 暴露检索工具,模型侧配置这样写
4.1 MCP Server 只暴露只读检索
复现 AI agent builder 那部分时,最容易走偏的是把 MCP Server 写得权限太大。它应该只做一件事:接收模型给出的查询意图,拼成 ES 查询,把命中的记忆文档返回。索引创建、mapping 变更、文档删除这些写操作,让模型生成语句、由你自己在 Kibana 或命令行里确认后执行。
同理,别给 MCP Server 配生产集群的写权限账号。只读账号、只读索引,出问题最多是查不到,不会变成改坏数据。
{ "mcpServers": { "es-memory": { "command": "python", "args": ["-m", "es_memory_server"], "env": { "ES_URL": "http://localhost:9200", "ES_INDEX": "agent_memory", "ES_READONLY_USER": "memory_ro", "TAOTOKEN_BASE_URL": "https://taotoken.net/api", "TAOTOKEN_API_KEY": "YOUR_API_KEY" } } } }Server 自己要不要调模型,取决于你的设计。如果冲突研判、摘要压缩放在 Server 里做,那就把上面两个TAOTOKEN_*变量透进去;如果模型调用全在 Agent 侧,Server 里可以不配,保持它只是一个检索工人。
4.2 Agent 侧兼容 OpenAI 的 client 配置
Agent 侧只要你用的是兼容 OpenAI 协议的客户端,改动就是两行。下面这段可以直接跑,把模型的输出限制成“只生成查询语句,不执行”。
import os from openai import OpenAI client = OpenAI( base_url=os.environ["TAOTOKEN_BASE_URL"], api_key=os.environ["TAOTOKEN_API_KEY"], ) resp = client.chat.completions.create( model=os.environ["TAOTOKEN_MODEL_ID"], messages=[ { "role": "system", "content": "你是记忆检索助手。只输出 Elasticsearch 查询 DSL,不执行任何查询,不编造索引名。" }, { "role": "user", "content": "找出这个会话里最近两周关于部署方式的记忆,按时间新鲜度加权。" }, ], ) print(resp.choices[0].message.content)拿到 DSL 之后由你的检索层执行,把命中的记忆塞回下一轮对话。整条链路里,模型只负责“想查什么”,ES 负责“查到什么”,两边职责分开。
4.3 长会话里 Token 到底被谁吃掉
一轮完整的长会话,模型至少被叫三次:一轮把用户输入抽成结构化记忆,一轮把历史记忆压缩成摘要,一轮做冲突研判。如果对话轮次多,抽取和摘要会反复触发。这部分消耗是跑长会话的主要成本来源,也是为什么建议把模型出口统一起来看用量——分散在多个 Key 上,你根本算不清一个会话跑完花了多少。
5. 跑一轮长会话:从模型对话页到控制台用量
5.1 单发一条消息验证通道
配置写完先别急着跑 Agent。打开 TaoToken 模型对话 ,用同一把 Key 发一条测试消息。这一步只验证三件事:Key 有效、模型 ID 拼写正确、账户有可用额度。三件事都过了,再去动代码。
如果这一步就报错,回去改 Key 和模型 ID,别怀疑 ES。把问题分层,能省掉一大半排查时间。
5.2 再让 Agent 走一次工具调用
通道确认之后,跑一轮最短的会话:输入一句带时间信息的偏好,让 Agent 抽取记忆并写入,然后换一句语义相近但结论相反的话,观察它是否把旧记忆排在后面。这一轮跑完,日志里应该能看到一次写入、一次检索、两到三次模型调用。数量对不上,说明有一环没走通。
5.3 用量对不上时的检查清单
回到 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 的控制台看这次调用有没有记上账。如果模型对话页有响应、控制台却没记录,通常是代码里用了另一把 Key,或者基址被OPENAI_BASE_URL之类的旧环境变量覆盖了。检查顺序建议从环境变量开始,再看代码里有没有硬编码的地址,最后看请求实际发出的域名。
6. 报错与“记忆串味”的排查顺序
6.1 401 类报错先看 Key 本身
401 Unauthorized基本只有三种原因:Key 复制时带了空格或换行、用的是另一个项目的 Key、环境变量没被当前进程读到。第三种最常见,尤其在 IDE 里改了.env但没重启运行环境。处理方式很简单,打印一下实际读到的 Key 前四位和后四位,和页面上对一遍。
6.2 404 和模型不存在,问题在路径和 ID
404十有八九是 Base URL 后面多写了/v1,或者干脆把落地页地址填成了 Base URL。填进客户端的一律是https://taotoken.net/api,末尾不带路径。如果返回的是“模型不存在”这类提示,去模型广场核对你填的那个 ID,别用记忆里的模型名。
6.3 Agent 又翻出旧结论,这是打分的事
有一种情况不报错,但结果很烦:Agent 又把三个月前的结论搬出来了。这不是模型通道的问题,模型只是照着上下文说话。先去看返回的候选集里老记忆排在第几,如果它排在前二,就调scale和decay,让时间衰减更狠一点;如果老记忆本来排在后面、模型还是选了它,那就在系统提示里加一条约束:记忆冲突时以event_time更新的为准。
把这两类问题分开,你会发现自己省下的时间比什么都多。
7. 把这次跑通的配置收进项目
7.1 配置分层:Key 放环境变量,结构放代码
跑通之后建议做一次整理。Key、Base URL、模型 ID 全部走环境变量,仓库里只留.env.example;索引 mapping、function_score模板、MCP Server 的只读账号配置进代码和版本控制。这样换环境时只改环境变量,ESC 结构不会跟着动。
7.2 下一步可以试的方向
下一轮可以试的方向有两个:一是把记忆压缩从每轮触发改成按阈值触发,减少无效的模型调用;二是给importance加一个自动评分环节,让模型在写入记忆时顺手给个权重,省掉人工标注。这两个改动都不碰 ES 的打分结构,属于纯应用层优化。
配置都跑顺之后,如果想换个方式验证,可以直接在 TaoToken 模型对话 里用同一把 Key 复述一遍上面的抽取提示词,看模型输出的 DSL 是否和本地一致;要长期挂着跑长会话,可以先看看 Coding Plan 的额度是否够用;Key 不够或者要按项目分开管理,去 控制台 API Keys 再建一把,命名上把项目和阶段带进去,回头对用量会轻松很多。如果你打算把 Agent 接到命令行工具里调试,Claude Code 那套环境变量的写法可以参考 接入文档 ,里面ANTHROPIC_BASE_URL这类变量填的同样是https://taotoken.net/api这一条基址。