1. 为什么 OpenHands 的 Memory 值得单独拆一节
如果你正在本地跑 OpenHands,大概率遇到过这种场景:任务跑到第 30 步,Agent 突然开始重复执行已经完成的命令,或者把前面确认过的文件路径又搞错了。这不是模型变笨了,而是 Memory 模块没有把关键历史正确压缩并注入到下一轮上下文里。OpenHands 的 Memory 不是简单的“聊天记录数组”,它由 View、ConversationMemory、Condenser 三层组成,分别负责事件过滤、消息格式化和历史压缩。理解这三层怎么协作,才能让 Agent 在长任务里保持逻辑连贯。
这一篇聚焦落地配置与验证,不铺开讲记忆系统的学术分类。我会给出可直接复制的config.toml骨架,把模型请求统一走 TaoToken 的 API 通道,然后通过两个可观测的动作确认 Memory 是否真的在工作:一是看 Condenser 是否在事件数超阈值时触发摘要,二是看 ConversationMemory 输出的消息列表里工具调用与响应是否配对完整。适合已经在本地装好 OpenHands、想调优长任务稳定性的开发者。
2. TaoToken 前置:统一 Key 与 API 通道
OpenHands 在运行时会多次调用 LLM:主 Agent 决策、Condenser 做历史摘要、可能还有浏览器观察压缩。如果每个环节各配一套 Key,调试时很难定位是哪个请求出了问题。TaoToken 提供统一的 API 入口,把模型对话、Coding Plan、控制台和 API Keys 管理集中在一个后台,本地开发时只需要维护一个 Key。
具体来说,你需要先拿到一个 API Key。访问控制台创建即可:
https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite创建后到 API Keys 页面复制完整 Key:
https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewriteOpenHands 的 LLM 配置走 OpenAI 兼容协议,所以 base_url 填https://taotoken.net/api,模型名按你实际使用的填。如果你还没确定用哪个模型,可以先在模型对话页面试几条长上下文指令,观察摘要质量:
https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite注意:API 地址不要加 UTM 参数,否则部分 OpenAI SDK 在拼接
/chat/completions时可能出问题。官网首页可以加 UTM 用于来源统计,但代码里的 base_url 保持干净。
如果你打算长期跑编码类 Agent 任务,Coding Plan 的额度模型比按次计费更适合反复调试 Condenser 阈值:
https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite3. 可复制的 config.toml 骨架
OpenHands 的配置文件通常放在项目根目录或~/.openhands/下。下面这份骨架把 LLM 通道、Memory 相关参数、Condenser 策略都列出来了。你只需要替换api_key和model两个值。
[core] # 工作目录,Agent 的文件操作默认限制在这里 workspace_base = "./workspace" # 单次任务最大步数,配合 Memory 观察长任务表现 max_iterations = 100 # 缓存目录,View 和事件流会落盘 cache_dir = "./cache" [llm] # 统一走 TaoToken 的 OpenAI 兼容通道 base_url = "https://taotoken.net/api" api_key = "sk-你的TaoTokenKey" model = "claude-sonnet-4-20250514" # 主 Agent 的温度,编码任务建议低一些 temperature = 0.2 # 单次请求最大输出 token max_output_tokens = 8192 [llm.condenser] # Condenser 单独指定模型,摘要任务可以用更便宜的模型 model = "claude-haiku-3-5-20241022" temperature = 0.0 max_output_tokens = 2048 [memory] # 是否启用 Condenser 管道 enable_condenser = true # 事件数超过这个值触发压缩 max_events = 80 # 保留最近的事件数,其余进入摘要 keep_first = 2 # 浏览器输出观察的最大保留数量 max_browser_outputs = 5 # 单条消息最大字符数,超出截断 max_message_chars = 8000 [memory.condenser_pipeline] # 压缩器执行顺序,先窗口限制再 LLM 摘要 condensers = [ "ConversationWindowCondenser", "BrowserOutputCondenser", "LLMSummarizingCondenser" ] [memory.condenser.llm_summarizing] # 摘要提示词模板文件路径,不填用内置默认 prompt_file = "" # 摘要中必须保留的段落标记 preserve_sections = ["USER_CONTEXT", "TASK_TRACKING", "CURRENT_STATE"]这份配置里最关键的是max_events和keep_first。max_events决定 View 里事件累积到多少条时触发 Condenser;keep_first保证最早的系统消息和初始用户指令不被压缩掉,否则 Agent 会丢失任务目标。我试过把max_events设成 40,结果摘要触发太频繁,反而增加了 LLM 调用次数;设成 120 又容易在复杂任务里撑爆上下文。80 左右对多数编码任务比较平衡。
4. 验证 Memory 是否生效的两个动作
配置写完不代表 Memory 真的在工作。你需要两个可观测的验证动作:一个看 Condenser 有没有产出摘要事件,一个看 ConversationMemory 输出的消息列表是否结构正确。
4.1 动作一:观察 CondensationAction 是否出现
启动 OpenHands 后跑一个需要多步的任务,比如“在当前目录创建一个 Python 项目,写三个模块并跑通测试”。任务执行过程中,查看事件流日志。OpenHands 会把事件写入cache_dir下的会话文件,通常是 JSON 格式。
# 找到最新的会话事件文件 ls -lt ./cache/sessions/ | head -5 # 统计 CondensationAction 出现次数 grep -c "CondensationAction" ./cache/sessions/<session_id>/events.json # 查看最近一次摘要内容 grep -A 20 "CondensationAction" ./cache/sessions/<session_id>/events.json | tail -40如果CondensationAction计数为 0,说明事件数还没到max_events阈值,或者 Condenser 管道没启用。你可以临时把max_events改成 10 来强制触发,验证管道通畅后再调回去。
摘要事件里应该包含summary和summary_offset两个字段。summary是 LLM 生成的压缩文本,summary_offset指示这个摘要应该插入到事件列表的哪个位置。View 的from_events方法会读取这两个字段,把摘要插入到正确位置,同时把被压缩的原始事件 ID 加入forgotten_event_ids集合。
4.2 动作二:检查消息列表的工具调用配对
ConversationMemory 的process_events方法负责把 View 里的事件转成List[Message]。这里最容易出问题的是工具调用和工具响应不配对:Agent 发起了CmdRunAction,但对应的CmdOutputObservation还没产生,或者因为压缩导致响应丢失。
你可以在 OpenHands 的调试日志里打开DEBUG级别,搜索_filter_unmatched_tool_calls的输出。如果看到有消息被过滤掉,说明存在不配对的工具调用。
# 在本地写个小脚本,直接调用 ConversationMemory 验证 from openhands.memory.conversation_memory import ConversationMemory from openhands.core.config import AgentConfig from openhands.utils.prompt import PromptManager config = AgentConfig() prompt_manager = PromptManager(config) memory = ConversationMemory(config, prompt_manager) # 构造一组测试事件:一个 Action 加一个对应的 Observation from openhands.events.action import CmdRunAction from openhands.events.observation import CmdOutputObservation action = CmdRunAction(command="echo hello") action.id = 1 obs = CmdOutputObservation(content="hello", command="echo hello") obs.id = 2 obs.tool_call_id = "call_001" messages = memory.process_events( condensed_history=[action, obs], initial_user_action=None, max_message_chars=8000, vision_is_active=False, ) for m in messages: print(m.role, getattr(m, "tool_calls", None), m.content[:80])预期输出里应该能看到一条assistant角色的消息带tool_calls,紧接着一条tool角色的消息带tool_call_id。如果tool消息缺失,说明_filter_unmatched_tool_calls把它过滤了,需要检查tool_call_id是否一致。
5. 本篇常见错排查
5.1 Condenser 不触发,事件一直累积
最常见的原因是enable_condenser没设成true,或者condenser_pipeline里的压缩器名称拼写不对。OpenHands 对压缩器类名是大小写敏感的,ConversationWindowCondenser不能写成conversation_window_condenser。另外检查max_events是否设得过大,导致任务在触发前就结束了。
5.2 摘要后 Agent 丢失任务目标
如果keep_first设成 0,最早的初始用户消息会被压缩进摘要,LLM 摘要时可能丢掉关键约束。建议keep_first至少为 2,保留系统消息和第一条用户指令。同时检查preserve_sections是否包含USER_CONTEXT和TASK_TRACKING,这两个段落是 Condenser 提示词里强制保留的。
5.3 工具调用与响应不配对导致消息被过滤
当 Agent 并发发起多个工具调用时,如果某个响应还没返回就触发了 Condenser,View 里可能只有 Action 没有 Observation。ConversationMemory 的_filter_unmatched_tool_calls会把这类消息整组过滤掉,导致 LLM 看不到工具调用历史。排查方法是看日志里pending_tool_call_action_messages字典是否在任务结束时还有残留。如果有,说明有工具调用永远没等到响应,需要检查工具执行超时设置。
5.4 TaoToken 通道返回 401 或 404
401 通常是 Key 没复制完整或有多余空格。404 多半是 base_url 写成了https://taotoken.net/api/带了尾部斜杠,OpenAI SDK 拼接后会变成//chat/completions。正确写法是https://taotoken.net/api,不带尾部斜杠。如果确认 Key 和地址都没问题,到接入文档页对照最新的参数说明:
https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite5.5 摘要质量差,Agent 反复问同样的问题
Condenser 用的模型如果能力太弱,摘要会丢失关键状态。建议 Condenser 单独配一个模型,不要和主 Agent 共用。摘要提示词里已经要求保留CODE_STATE、TESTS、CHANGES等段落,但如果你的任务不是编码类,这些段落会显得冗余。可以在prompt_file里指定自定义模板,把段落改成适合你任务类型的字段。
6. 把 Memory 调稳之后,下一步做什么
Memory 配置调通后,你会明显感觉到 Agent 在长任务里的行为更稳定:不会重复执行已完成的步骤,也不会因为上下文溢出而突然“失忆”。接下来可以做的优化方向有两个:一是把 Condenser 的摘要结果持久化到外部存储,跨会话复用;二是针对特定任务类型自定义 View 的过滤规则,把不相关的事件类型提前排除。
如果你还在选模型阶段,建议先在模型对话页面用长上下文指令测试摘要质量,确认模型能稳定输出结构化摘要后再接入 OpenHands。长期跑编码任务的话,Coding Plan 的额度模式比按次调用更适合反复调试 Condenser 阈值。接入过程中遇到报错,优先对照接入文档检查 base_url 和 Key 格式,这两个地方占了本地调试问题的大半。