开源交易 Agent 项目 Vibe-Trading 的上下文管理:组装、压缩工具与记忆压缩

2026-08-05

本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。

**Vibe-Trading 这套上下文管理里最值得抄的一条,是它把「压缩」拆成了三种性质完全不同的东西:一次不花钱的字符串裁剪、一次要调模型的结构化摘要、一次发生在磁盘上的长期降级;而模型能主动调用的那个 compact 工具,本身什么都不做。**先把名字说清楚:Vibe-Trading 是 HKUDS 放出的那个开源个人交易 Agent 项目的项目名,不是「凭感觉做交易」这类泛指说法,本文说的全部是这一个仓库里的代码。很多人读 Agent 源码时会把这三件事混成一团,然后在自己的项目里写出一个既想省 token 又想保信息、结果两头都不讨好的压缩函数。Vibe-Trading 把它们分开了,分界线还挺清楚。

先说清楚本文的口径:这是一个金融研究方向的开源 Agent 项目,但下面只讲它的 Agent 工程实现——代码怎么组织、数据结构长什么样、哪个常量控制哪个行为。不讨论任何投资方法,也不评价它内置的任何东西是否管用。历史表现不代表未来,本文只讨论工程实现。

站内已有的三篇相邻内容分工不同:Agent 上下文管理的通用做法讲的是不挑框架的原则,opencode 的上下文压缩拆的是另一个项目的单点实现,上下文工程怎么做是方法论层;本篇只做一件事——把 Vibe-Trading 这一个仓库里从「组装」到「压缩」到「记忆降级」的完整链路读一遍,指出每个决策点的常量在哪个文件的第几个位置。

一、上下文压力从哪来

先建立一个体感。这个仓库全量受版本控制的文件有 2030 个,agent/ 目录下 1805 个,frontend/ 155 个;agent/src/ 下有 23 个模块目录。跟上下文直接相关的是这几块:agent/src/skills/ 下有 88 个技能目录、合计 404 个文件;agent/src/tools/ 有 72 个文件;agent/src/swarm/presets/ 放着 30 份多智能体编制 yaml;agent/src/factors/ 有 482 个文件。

这些数字自己不进上下文,但它们决定了系统提示词有多重。agent/src/agent/context.py 里的 _SYSTEM_PROMPT 是一个带占位符的长模板,开头就要填进 {skill_count}{tool_count}{data_source_count} 三个统计值,中间要塞 {tool_descriptions}{skill_descriptions} 两大块,后面还有一整段任务路由规则——回测、多智能体团队、分析研究、文档与网页、交易流水、影子账户,每条路由都写了该先加载哪个技能、该按什么顺序调工具。光这段路由就有近百行。

顺带说明一处容易被写错的事实:agent/src/factors/ 下的因子实现不是这个项目凭空自研的。仓库根目录的 NOTICE 声明了各因子库的上游来源与许可——其中 Microsoft Qlib 的特征定义走 Apache 2.0,另有几组公式来自公开论文与研报,仓库把它们当作数学事实做了重实现;各因子库子目录下另有 LICENSE.md。能不能商用请以许可证原文为准,本文不提供法律意见。

再叠上研究类任务本身的特点:一轮下来可能读几个文件、抓几段网页、跑一次回测再读产物 CSV。工具返回值不像聊天那样几十个字符,动辄几千。上下文涨得快,是任务形态决定的,不是模型不行。

组成部分它负责什么对应仓库位置你什么时候会碰到它
ContextBuilder组装系统提示词与消息列表,注入技能摘要、工具描述、工作区状态、记忆召回agent/src/agent/context.py改提示词结构、想给模型多注入一类信息时
五层压缩调度按 token 水位依次触发裁剪、折叠、摘要,并修复被压坏的工具消息配对agent/src/agent/loop.py长任务跑到一半、模型开始「忘事」时
CompactTool一个只有可选 focus_topic 参数的空壳工具,供模型主动请求压缩agent/src/tools/compact_tool.py想让模型自己判断该压缩时
CompressionPipeline磁盘上的记忆条目按闲置天数从 raw 降到 daily 再降到 digestagent/src/memory/compression.py开了跨会话记忆、条目越攒越多时
GC 与压缩的联动先按重要度归档/删除,再对老条目跑压缩;两个开关缺一不可agent/src/memory/lifecycle.py配置记忆生命周期、排查「压缩没生效」时

二、组装层:ContextBuilder 决定什么进提示词

ContextBuilder 的构造参数是工具注册表、工作区记忆、技能加载器,外加一个可选的跨会话持久化记忆。它对外主要两个方法:build_system_promptbuild_messages

build_system_prompt 里有三个值得抄的取舍。

第一个是技能只注入一行摘要。它调 skills_loader.get_descriptions(),那个方法把技能按 category 分组,每条只输出 - 名字: 描述 一行,完整文档留给模型用 load_skill 工具按需读。88 个技能目录,如果全文注入,第一轮就没法开工了。

第二个是跨会话记忆快照在会话开始时冻结。方法的 docstring 写得很直白:快照在 session 启动时定住,为的是保住 prompt 缓存。这是个很实际的判断——系统提示词一变,缓存就作废;与其让记忆实时刷新系统提示词,不如让它在别的地方进来。

第三个是统计值从真源头推导,避免提示词漂移_count_data_sources 这个静态方法去 import backtest.loaders.registryVALID_SOURCES,减掉 "auto" 这个跨市场选择器再取长度,注释里明说这是为了让提示词里的数字永远跟实际加载器数量对齐;import 失败才回退到一个写死的兜底值。工具数和技能数同理,直接取 len(self.registry._tools)len(self.skills_loader.skills)。你在自己项目里写「我有 N 个工具」时,最好也别手写那个 N。

那记忆怎么进来?答案在 build_messages。它先放系统消息、再放历史,然后对当前这条用户消息做一次自动召回:调 find_relevant(user_message, max_results=3),把命中的条目格式化成「标题(类型):正文前若干字符」的行,包进一对 <recalled-memories> 标签,拼在用户原话前面。整段召回逻辑套在 try 里,失败只写一条 debug 日志,绝不阻断主流程。

这个设计的分工很干净:系统提示词求稳定(可缓存),用户消息求相关(按 query 变)。想给 Agent 加「记住我的偏好」这类能力时,默认冲动是往系统提示词里塞,那会把缓存打碎;Vibe-Trading 走的是另一条路。

系统提示词里还有一小块 {memory_summary},来自 WorkspaceMemory.to_summary()。这个工作区记忆轻得出奇——只有一个 run_dir 和一个工具调用计数字典,输出两行文本,空的时候返回 (empty state)。它的 docstring 点明了存在理由:这份摘要要能扛过上下文压缩,帮模型记住自己刚才在干什么。后面你会看到压缩重建消息列表时,确实把它又拼回去了。

三、五层压缩:分水位触发,compact 只是开关

agent/src/agent/loop.py 的模块 docstring 把整套设计一次性交代完了:

Five-layer context management:
  Layer 1 (microcompact)     — prunes old tool results once under memory pressure
  Layer 2 (context_collapse) — folds long text blocks without LLM call (zero cost)
  Layer 3 (auto_compact)     — LLM structured summary with token-budget tail protection
  Layer 4 (compact tool)     — model explicitly calls the compact tool to trigger L3
  Layer 5 (iterative update) — Nth compression updates previous summary instead of starting fresh

主循环每轮开头先用 estimate_tokens 粗估一次规模——实现就是把整个消息列表 dump 成 JSON 再除以 4,注释直言是「约 4 字符一个 token」的粗估。然后按三档水位逐级升级:超过阈值的一半跑第一层,超过七成跑第二层,越过阈值本身才跑第三层。每跑完一层重新估一次,够用就不往下走。阈值本身来自配置,可被测试覆盖,本文不列具体数值。

第一层 _microcompact 只动 role == "tool" 的消息:保留最近 KEEP_RECENT 条完整,更早的里凡是内容超过 100 字符的,整个替换成字符串 [cleared]。触发条件那段注释交代了为什么要卡在半程再动手——短任务、低压力的运行应该保留完整工具历史供模型回查,而不是一上来就把除最近几条外的全部清掉。

第二层 _context_collapse 不区分角色,跳过系统消息和最近若干条,对超长文本做「留头留尾、折叠中间」:保头部若干字符、保尾部若干字符,中间换成 ...[N chars collapsed]...,N 是被砍掉的字符数。它显式跳过已经是 [cleared] 的消息。这一层纯字符串操作,注释里标了「零 API 成本」。

第三层 _auto_compact 才真花钱,逻辑也最密。按顺序看:

  1. 先把当前完整消息列表原样写成 transcript_<时间戳>.jsonl,落在 trace 目录里。压缩前先留底,这一步在很多实现里是缺的。
  2. 保住 messages[0](系统消息),其余作为 body。
  3. 从后往前累加,按一个固定的 token 预算划出「尾部」——源码里叫 TAIL_TOKEN_BUDGET,尾部原样保留,前面的部分交给模型摘要。这是按预算保尾,不是按条数保尾,比「保留最近 N 条」抗抖动。
  4. 切点如果落在一条 role == "tool" 的消息上就往后挪,避免把工具调用和它的结果劈开。
  5. 如果全部 body 都在尾部预算内,会强制对半切,注释写明是为了避免死循环。

摘要提示词是硬结构的,要求模型严格按固定小节输出:Goal、Constraints & Preferences、Progress(下分 Done / In Progress)、Key Decisions、Resolved Questions、Pending User Asks、Relevant Files、Remaining Work,末尾还有 Critical Context(专收具体数字、参数、报错信息、配置值)和 Tools & Patterns(哪些工具管用、哪些失败过)两节。开头那句约束很关键——「这份摘要是唯一可用的上下文,漏掉的信息就丢了」。Resolved Questions 这一节尤其实用,它明确写着「不要重新回答这些」,专治压缩后 Agent 把已经问过的问题再问一遍。

第五层藏在同一个方法里:实例上存着 _previous_summary,如果不是第一次压缩,走的是另一套「增量更新」提示词——把上次的摘要和新增轮次一起给模型,规则是保留旧摘要全部信息、追加新进展、把 In Progress 里完成的挪到 Done、把已答的挪到 Resolved Questions、保持小节结构不变。这样第 N 次压缩不是对摘要再摘要,避免了逐次衰减。

第四层就是那个 compact 工具,而它的实现只有一句:

    def execute(self, **kwargs: Any) -> str:
        return json.dumps({"status": "ok", "message": "Compression triggered"})

真正的动作在主循环里。工具调用预处理阶段一旦看到名字是 compact,就置一个标志、往消息里补一条 {"status":"ok","message":"Compressing..."} 的工具结果、往 trace 写一条 compact_requested,然后跳过实际执行;等本轮其它工具都跑完,主循环再调 _auto_compact,把 focus_topic 透传进去。给了 focus_topic 时,摘要提示词会追加一段,要求把六到七成的摘要篇幅分配给该话题,其余内容激进压缩。

这个「工具只做标记、动作延后到循环里」的写法值得单独记一笔。压缩要改的是消息列表本身,而工具执行的语境正在遍历这个列表——就地修改会把当前这轮的工具配对搞乱。延后到轮末,顺序就干净了。

压缩完还有一步收尾:_fix_tool_pairs 双向修复孤儿——删掉那些对应 tool_call 已被压掉的工具结果,再给那些结果被压掉的 tool_call 补上占位。少了这一步,多数 provider 会直接报格式错。重建后的消息列表是:系统消息 + 一条 user 消息(内含压缩标记、transcript 路径、摘要正文、以及那份工作区状态摘要)+ 保留的尾部。

压缩事件还会往外发一个 compact 事件。agent/src/openbb_bridge/event_mapper.py 把它映射成一条给用户看的提示,正文是「Context compacted to stay within limits.」,详情挂截断后的摘要。压缩对用户可见,不是静默发生的。

四、记忆侧的三级压缩:raw / daily / digest

前面三层都在管一次运行内的消息列表。agent/src/memory/compression.py 管的是另一件事:磁盘上的长期记忆条目,随着时间推移主动降级。这属于记忆分层那一层的问题,跟消息列表压缩不是一回事,别混着调。

分级和阈值都是模块级常量,一眼看得完:

DAILY_THRESHOLD_DAYS = 7
DIGEST_THRESHOLD_DAYS = 30

DAILY_TOP_K_SENTENCES = 5  # Keep top-5 sentences + first/last
DIGEST_MAX_TOKENS = 50  # Max tokens in digest
DIGEST_TOP_KEYWORDS = 15  # Top keywords for digest bullet list

should_compress 的判断只看两件事:当前级别,以及距上次访问过了多少天。raw 且超过前一个阈值就降到 daily,daily 且超过后一个阈值就降到 digest,已经是 digest 或者时间没到就返回 None。注意口径是 last_accessed 而不是创建时间——最近还在被翻出来用的条目不会被降级。

降到 daily 用的是 TF-IDF 抽句,全程不调模型。分句正则同时处理英文的 .!? 和中文的 。!?,也在换行处切。分词正则的写法值得注意:三个字符及以上的 ASCII 词,或者单个非拉丁字符——涵盖了 CJK 统一表意文字、CJK 扩展 A、泰文、阿拉伯字母、希伯来字母、西里尔字母。也就是说中文按单字成词。IDF 用 log(N / (1 + df)),句子得分是句内各词 IDF 之和除以词数(做了长度归一,长句不占便宜)。抽句时首句和末句恒定保留,中间按分数取前几名,最后按原顺序重排。有个短路分支:句子总数不超过 top_k + 2 就原样返回,因为已经够短了。有关键词的话,在头部加一行 Keywords: ...

降到 digest 就狠了。它对 daily 内容做词频统计,用句级 IDF 加权算 tf * idf,排序取前若干个词,输出格式是一行 Context: ... 加一行 Key concepts: 再跟一串词的列表。句子结构没了,只剩词。这一级的定位不是「读懂旧内容」,而是「知道曾经有过这么件事,还能被关键词检索命中」。想清楚这个定位很重要,否则你会觉得 digest 级的输出「质量太差」。

archive_original 是这套设计里的良心:压缩前先把原文件复制进 archive/ 目录,走的是「先写 tmp 再 os.replace」的原子替换。更关键的是 apply_compression 的这段判断——如果归档失败但源文件确实存在,直接中止本次压缩并打 error 日志,理由写在注释里:避免数据丢失。压缩是有损的,那就必须先有一份无损备份,备份不成功就不许有损。

最后是 estimate_retention,把原文和压缩结果各自切成词集合,算 Jaccard 交并比,作为「信息保留率」写进日志。它不做决策、不做拦截,纯观测。但有这个数在日志里,你至少能回头判断某次压缩是不是砍过头了。

触发链路在 agent/src/memory/lifecycle.pyrun_gc:先按重要度对条目做归档或删除,写 GC 日志,然后才轮到压缩这一段——遍历条目、问 should_compress、拿到目标级别就 apply_compression,成功再把新正文和 frontmatter 里的级别字段写回去。整段包在 try/except 里,失败只留 debug 日志,不影响 GC 本身。

这里有个必须知道的配置耦合:压缩是特性开关控制的,默认关闭;而且它挂在 GC 流程里,GC 不开压缩就永远不会跑。源码里专门为这个情况加了一条 warning,提示压缩开了但 GC 没开、压缩不会触发。

五、边界与代价

这套组合放弃了不少东西,说清楚才好判断要不要抄。

记忆侧的压缩完全不理解语义。 TF-IDF 抽句是统计方法,它不知道哪句是结论、哪句是废话。一段里如果结论句用词很常见、跑题句里全是生僻词,被留下的很可能是后者。digest 级更是直接退化成词袋。这是拿质量换成本和确定性——好处是零 API 调用、可复现、离线可跑,坏处是你不能指望降级后的条目还能被完整读懂。

前两层压缩会真丢东西,而且不可逆。 [cleared] 和「中间折叠」都是就地改消息,改完原内容在内存里就没了。第三层有 transcript 落盘兜底,前两层没有。所以如果某个工具结果是后续步骤的唯一依据,别指望它一直在。

摘要那一层依赖模型自觉。 提示词写了「漏掉就丢了」,但没有任何机制去校验模型是不是真把关键信息抄进去了。它没有对摘要做结构校验或字段级断言,_previous_summary 也只是原样存下模型返回的文本。

它明确不管的事。 压缩不管你的模型上下文能装多少,阈值是配置里给的,配错了它照跑不误;不管跨会话的消息历史,那是另一套;不管 tokenizer 差异,estimate_tokens 就是字符数除以 4;WorkspaceMemory 也不做持久化,只活在一次运行里。

还有一块代价跟这个项目的领域直接相关,必须写清楚。仓库里 agent/src/trading/connectors/ 有 12 家券商连接器子目录(README 亦自述 12 brokers),agent/src/channels/ 有 16 个具体渠道实现文件(另有 base、manager、registry 等公共文件)。凡是走到实盘下单、券商连接、资金授权这一步,代价跟上面这些工程取舍完全不是一个量级:凭据的暴露面会随着接入的渠道数量线性放大——多一个聊天渠道就多一条能触达 Agent 的入口;下错单不可撤销,压缩掉一条关键上下文导致的误判,在只读研究里是重跑一次的事,在下单链路上不是;程序化交易的合规义务因司法辖区而异。能不能这么用,以你所在司法辖区的监管要求与券商协议为准,本文不提供任何判断。

六、上手与避坑清单

开了压缩却发现记忆条目纹丝不动。 因为压缩挂在 GC 流程内部,压缩开关和 GC 开关必须同时开。源码为这个坑专门加了 warning,但那条日志是 warning 级别,日志过滤稍微严一点就看不见了。启用前先确认两个开关都到位。

以为 compact 工具里能写压缩逻辑。 它的 execute 只返回一个固定 JSON,真动作在主循环。如果你 fork 后想改压缩行为却去改这个工具文件,改一天也没反应。另外它的 is_readonly 标为 False,这会影响它在并行批处理里的调度位置——只读工具才会被合批并行执行。

压缩后模型开始重复提问或者重复调工具。 先去 trace 目录找那次的 transcript_<时间戳>.jsonl,对着摘要看哪一节漏了。摘要模板里的 Resolved QuestionsPending User Asks 就是为这类问题准备的,漏的通常是这两节。也可以在下次压缩时给 focus_topic,把篇幅压到你关心的主题上。

自己实现类似压缩时把工具配对搞坏。 只要你按位置切分消息列表,就一定会遇到 tool_call 和它的结果被分到两边。Vibe-Trading 用了两道保险:切点落在工具消息上就往后挪,压缩重建后再跑一次双向的孤儿修复。少任何一道,provider 都可能直接拒收请求。

把 daily/digest 级的记忆当原文用。 digest 级只剩关键词列表。要看原文得去 archive/ 目录里翻归档,那才是无损副本。设计上归档是压缩的前置条件,归档失败会中止压缩,所以归档大概率在——但你得知道去哪儿找。

按字符数估 token 后误判水位。 estimate_tokens 是 JSON 长度除以 4,对中文内容会明显偏离。它够用是因为三层水位本来就是粗档位,但你要拿这个数去做精细的成本核算,会算错。

误以为压缩会让提示词也变小。 不会。系统消息在压缩里是被完整保留的第一条,那份长模板一直在。要减系统提示词的量,得从技能摘要、工具描述的写法入手,那是 context.py 的事,跟三层压缩无关。

收束

要照抄这套设计,先回答自己三个问题:你的裁剪层有没有留底、你的切分会不会劈开工具配对、你的长期记忆降级后有没有无损副本可回溯。这三条是 Vibe-Trading 在这条链路上给出的最硬的判断,其余都是参数。

接着读哪个文件也很清楚:想改提示词结构和记忆注入方式,读 agent/src/agent/context.py;想改压缩水位和摘要模板,读 agent/src/agent/loop.py 的模块常量区和 _auto_compact;想改长期记忆的降级策略,读 agent/src/memory/compression.py 的常量区,再顺着 agent/src/memory/lifecycle.pyrun_gc 看它是怎么被调起来的。想验证行为,仓库里 agent/tests/memory/test_compression.py 是现成的对照。

顺带一提,这个项目的 README 有 5 个语言版本,许可证是 MIT(Copyright 2026 Vibe-Trading Contributors)。它的配置文件里确实有一些要求模型按固定字段输出的约束——那是仓库对模型输出格式的规定,不是本文对你的任何建议。本文自始至终只讨论工程实现。

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 源码:Agent 主循环与 runner 的分工Vibe-Trading 开源项目的记忆分层:四个模块与它们的新麻烦

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。