Skip to content

11_Tech_Agent_Design

youngcan edited this page Jun 18, 2026 · 48 revisions

Agent 设计专题

本页记录 WyckoffAgent 当前代码中的 Agent(智能体)工程设计,重点解释 CLI(命令行)/ TUI(终端图形界面)里的完整 Agent Runtime(智能体运行时),以及 Web(网页端)、MCP(模型上下文协议)如何复用工具层但采用不同运行时。

英文术语速查

英文术语 中文释义
Agent 智能体:能根据目标自主选择工具、多轮执行并输出结果的程序
Runtime 运行时:负责模型调用、工具执行、循环控制、上下文压缩和追踪
CLI 命令行界面:通过终端命令使用系统
TUI 终端图形界面:在终端里显示类似 GUI 的交互界面
Web 网页端:浏览器里的在线读盘室
CF Pages Cloudflare Pages 边缘页面:承载 Web 前端和边缘函数
MCP 模型上下文协议:给 Claude Code、Cursor 等外部 Agent 暴露工具的协议
Provider 模型适配层:把不同模型供应商统一成同一套调用接口
Tool Schema 工具定义:告诉模型工具名、用途和参数格式
Tool Call 工具调用:模型决定调用某个工具并给出参数
ReAct 推理-行动循环:模型先推理,再调用工具,观察结果后继续推理
Loop Guard 循环守卫:运行时强制模型补调必须工具的保护机制
Doom-loop 死循环:模型反复用同一参数调用同一工具
Scratchpad 运行追踪文件:记录一轮对话中的模型、工具、压缩和最终结果
Sub-agent 子智能体:只负责某类任务的小 Agent
Context Window 上下文窗口:模型一次请求能容纳的最大上下文长度
Compaction 上下文压缩:把旧对话压成摘要,给后续轮次腾空间
Memory 记忆:跨会话保留的稳定偏好和决策逻辑
Fallback 故障切换:主模型失败时自动换备用模型
Reasoning 推理内容:部分模型输出的内部思考过程
Token 令牌:模型上下文长度和计费的基本单位
SSE 服务端事件流:流式响应的一种传输格式
Daemon Thread 守护线程:后台线程,进程退出时自动回收
System Prompt 系统提示词:约束模型行为的顶层指令
Skill 技能模板:预设的一类用户任务提示

0. 当前实现边界

WyckoffAgent 不是单一入口,而是三条通道共享同一套金融能力:

通道 入口 运行时 对话/工具编排 适合场景
React Web(网页端)/ CF Pages(Cloudflare Pages 边缘页面) web/apps/web/ Vercel AI SDK(Vercel 的 AI 工具包)streamText(流式文本生成) Web 端独立工具循环,stopWhen(stepCountIs(10))(达到 10 步就停止) 在线读盘室、浏览器用户
CLI(命令行)/ TUI(终端图形界面) wyckoff cli/runtime.py::AgentRuntime 完整 ReAct loop(推理-行动循环)、记忆、上下文压缩、后台任务、sub-agent(子智能体) 本地深度投研和工具型 Agent
MCP Server(模型上下文协议服务) wyckoff-mcp FastMCP stdio(标准输入输出通信) 无内置对话 loop(循环),只暴露工具 Claude Code / Cursor 等外部 Agent 调用

共享层是 agents/chat_tools.py 和核心分析引擎;不同入口只是在运行时、权限、展示和状态管理上分化。

关键边界:

  • CLI/TUI 是最完整的 Agent Runtime(智能体运行时):负责 provider(模型适配层)调用、工具执行、并发分批、上下文压缩、Loop Guard(循环守卫)、doom-loop(死循环)检测、scratchpad(运行追踪文件)、后台任务和 sub-agent(子智能体)。
  • Web 不复用 AgentRuntime:它使用 web/apps/web/src/lib/chat-agent.ts 中的 Vercel AI SDK(Vercel 的 AI 工具包)工具循环,保留流式输出、工具调用、上下文本地摘要和 reasoning(推理内容)透传,但不接入 CLI 的 SQLite(本地轻量数据库)记忆、scratchpad(运行追踪文件)和 sub-agent(子智能体)。
  • MCP 不具备会话智能mcp_server.py 只是把 Wyckoff 能力注册成 MCP tools(模型上下文协议工具),推理和多轮编排由外部客户端负责。

1. CLI Agent Runtime(命令行智能体运行时)

核心实现:cli/runtime.py::AgentRuntime

它把一次用户请求拆成一个 provider-agnostic(不绑定具体模型供应商)的事件循环。这里保留两张图:

  • 简版图:用于快速理解 Agent Runtime(智能体运行时)的主循环,适合作为本节的入口视图。
  • 详版图:用于展开工程细节,说明记忆、压缩、工具审批、后台任务、大结果落盘、错误回灌和循环保护。

简版:核心闭环

flowchart TD
    U["用户消息"] --> M["记忆召回 / 系统提示"]
    M --> C["上下文检查与压缩"]
    C --> L["provider.chat_stream"]
    L --> K["消费 chunk 流式片段"]
    K -->|text_delta| T["流式渲染"]
    K -->|thinking_delta| R["推理内容记录"]
    K -->|tool_calls| A["追加 assistant tool message"]
    A --> B["按工具并发安全性分批"]
    B --> E["执行工具"]
    E --> O["tool observation 回灌 messages"]
    O --> C
    K -->|无工具调用| G["Loop Guard 循环守卫检查必需工具"]
    G -->|漏调| P["注入 retry user message"]
    P --> C
    G -->|完成| D["done event / 保存日志"]
Loading

简版图只表达一件事:Agent 不是一次模型问答,而是“模型推理 → 工具执行 → 观察结果回灌 → 再次推理”的闭环。先看这张图可以快速建立整体理解,再继续阅读后面的工程细节。

详版:工程展开

详版图沿着三条线展开:

  1. 模型前处理:识别必需工具、召回记忆、必要时压缩上下文。
  2. 工具执行治理:最大轮数、并发/串行分批、高风险审批、后台任务、死循环检测。
  3. 模型后处理:工具结果或错误回灌、大结果落盘、漏调必需工具时纠偏、最终保存日志。
flowchart TD
    U["用户消息"] --> X["resolve_turn_expectation<br/>识别本轮必需工具"]
    X --> M["build_memory_context<br/>召回相关记忆"]
    M --> I["prepend_memory_context<br/>临时注入 relevant-memories"]
    I --> C{"估算 token(令牌)是否超过<br/>压缩阈值"}
    C -->|是| C0["flush_memory_before_compaction<br/>压缩前提取持久偏好"]
    C0 --> C1["复制 head 并运行<br/>shrink_stale_tool_results"]
    C1 --> C2["compact_messages<br/>生成 500 字以内摘要"]
    C -->|否| L
    C2 --> L["provider.chat_stream<br/>模型流式调用"]

    L --> TO["_iter_with_timeout<br/>60 秒无 chunk 则超时"]
    TO --> K["消费 chunk<br/>流式片段"]
    K -->|text_delta| T["RuntimeEvent:text_delta<br/>TUI 流式渲染"]
    K -->|thinking_delta| R["RuntimeEvent:thinking_delta<br/>记录推理内容"]
    K -->|usage| U1["RuntimeEvent:usage<br/>累计 token 用量"]

    K -->|tool_calls| A["追加 assistant tool message<br/>保留 tool_call_id"]
    A --> MR{"超过 MAX_TOOL_ROUNDS=15"}
    MR -->|是| ML["done:工具调用轮次超限"]
    MR -->|否| B["partition_tool_calls<br/>按并发安全性分批"]
    B --> BC{"concurrency_safe"}
    BC -->|是| P1["ThreadPoolExecutor<br/>最多 5 线程并发"]
    BC -->|否| P2["串行执行<br/>避免写操作互相踩踏"]
    P1 --> DL
    P2 --> DL

    DL{"doom-loop 检测<br/>最近 6 次内重复 3 次"}
    DL -->|触发| AB["追加 tool_error<br/>中止本轮工具批次"]
    DL -->|未触发| AP{"requires_approval<br/>是否高风险工具"}
    AP -->|是| CF["确认弹窗<br/>允许 / 总是允许 / 修改 / 拒绝"]
    AP -->|否| BG
    CF --> BG{"background<br/>是否后台任务"}
    BG -->|是| BT["BackgroundTaskManager<br/>返回 task_id"]
    BG -->|否| EX["ToolRegistry.execute<br/>执行确定性 Python 函数"]
    EX --> ER{"工具是否报错"}
    ER -->|是| TE["tool_error<br/>错误回灌给模型"]
    ER -->|否| TR["tool_result<br/>结果回灌给模型"]
    BT --> TR
    TE --> FMT
    TR --> FMT["format_tool_result_for_context<br/>结果超过 8,000 字符落盘"]
    FMT --> O["追加 tool observation<br/>写回 messages"]
    AB --> O
    O --> C

    K -->|无工具调用| G{"Loop Guard<br/>必需工具是否漏调"}
    G -->|漏调且重试小于 2| RT["注入 retry user message<br/>要求直接调用必需工具"]
    RT --> C
    G -->|漏调且重试耗尽| W["追加不可靠警告"]
    W --> D
    G -->|未漏调| D["done event<br/>保存 chat log / scratchpad"]
    ML --> D
Loading

详版图不是要求一次性读完,而是作为工程细节索引。需要理解大结果处理时,可以看 format_tool_result_for_context 这条线;需要理解模型漏调工具时,可以看 Loop Guard 这条线;需要理解重复工具调用保护时,可以看 doom-loop 这条线。

run_stream() 只产出统一的 RuntimeEvent(运行时事件)字典。TUI(终端图形界面)、headless wrapper(无界面兼容封装)、sub-agent(子智能体)和测试都消费这些事件,而不是各自重新实现模型调用和工具循环。

两张图共同强调两个设计重点:

  • 模型前后都由确定性代码兜底:进入模型前先做记忆召回和必要的上下文压缩;模型返回后再做工具分批、审批、后台任务、大结果落盘和错误回灌。工具 observation(观察结果)写入后尽量保持稳定,避免破坏 prompt cache(提示词缓存)。
  • 结束条件不是“模型说完了”:无工具调用时还要经过 Loop Guard(循环守卫)检查;漏调必需工具会注入 retry user message(纠偏用户消息),最多 2 次,仍失败才带警告结束。

主要事件:

事件 来源 用途
text_delta Provider(模型适配层)流式文本 TUI(终端图形界面)逐行渲染
thinking_delta / thinking reasoning chunk(推理片段)/ round(轮次)结束 推理展示和 scratchpad(运行追踪文件)记录
tool_calls 模型请求调用工具 清除临时流式文字,进入工具执行
tool_start 工具开始 展示工具名、参数、spinner(加载动画)
tool_result / tool_error 工具结束 回灌 observation,更新执行摘要
model_start 多轮工具调用的新一轮模型推理 恢复“思考中”状态
compaction 上下文被压缩 提示消息条数变化
retry Loop Guard(循环守卫)触发 强制模型补调用必需工具
usage Provider(模型适配层)token(模型令牌)统计 状态栏计费/用量
done 最终回答 保存 chat log(聊天日志),结束本轮

2. Provider(模型适配层)抽象

Provider(模型适配层)接口定义在 cli/providers/base.py

class LLMProvider:
    def chat(self, messages, tools, system_prompt="") -> dict: ...
    def chat_stream(self, messages, tools, system_prompt="") -> Generator[dict, None, None]: ...
    @property
    def name(self) -> str: ...

Provider(模型适配层)的职责是把不同模型 SDK(软件开发工具包)的响应转换成统一 chunk(流式片段):

  • text_delta:普通文本。
  • thinking_delta:DeepSeek 等模型返回的 reasoning(推理内容)。
  • tool_calls:标准化为 {"id", "name", "args"}
  • usage:输入/输出 token(模型令牌),以及可用时的 cache read/write token(缓存读写令牌)。

滑动提示词缓存 (Sliding Prompt Caching)

为了降低 API 的输入(Input)开销,缩短首字响应延迟(TTFT),Claude Provider (cli/providers/claude.py) 实现了高度精准的滑动 Prompt 缓存(Prompt Caching)机制。

1. 缓存生命周期与工作机制

  • 静态基底缓存 (Static System Prompt Caching):系统提示词作为前置静态上下文,被包装为 Block 格式并标记 "cache_control": {"type": "ephemeral"}。由于系统提示词在整个会话中保持不变,它构成了缓存的稳定起点。
  • 滑动边界推进 (Sliding Boundary Promotion):在每次向 Claude 发起 API 请求时,系统自动识别当前消息序列的最后一条消息,并在其最后一个 Content Block 上附加 "cache_control": {"type": "ephemeral"}
    • 随着对话轮次的不断累加,这个缓存控制标记会随最后一轮消息向后滑动推进
    • 这种设计使 LLM 服务端能够缓存当前轮次之前 100% 的历史会话内容。后续对话发起时,只需计算并写入最新一轮增量消息的 Token,前序的几万 Token 均可通过缓存读取,最高可减免 90% 的输入 Token 计费并大幅降低时延。

2. 统一消息协议的缓存适配

在底层消息格式构建函数 _build_messages() 中,系统根据消息角色 (role) 分别实现了细粒度的缓存注入:

  • 用户消息 (User):将文本内容包装为 Block 列表,在 text 块上注入缓存控制标记:
    {
        "role": "user",
        "content": [{"type": "text", "text": msg["content"], "cache_control": {"type": "ephemeral"}}]
    }
  • 助手消息 (Assistant):合并 content 中的文本块与 tool_use 块,在最后一个内容 Block 上追加缓存标记:
    content[-1]["cache_control"] = {"type": "ephemeral"}
  • 工具返回结果 (Tool):在转换后的 tool_result 内容 Block 上附加缓存标记:
    content_block = {
        "type": "tool_result",
        "tool_use_id": msg.get("tool_call_id", ""),
        "content": result,
    }
    content_block["cache_control"] = {"type": "ephemeral"}

3. 缓存命中指标的度量收集

在流式响应解析 chat_stream() 中,系统通过监听 Claude SDK 的 message_start 事件,从其 usage 元数据字段中精准提取缓存相关的计数指标:

  • cache_read_input_tokens (缓存读取命中数):代表成功从服务端缓存中直接复用的 Token 数量(费率极低)。
  • cache_creation_input_tokens (缓存创建写入数):代表本轮新增写入缓存的 Token 数量(费率等同于普通输入 Token)。

这些指标被统一包装在返回的 usage 信息中并同步更新至 TUI 状态栏,为用户提供直观的成本控制和延迟优化统计反馈。

当前 CLI(命令行)主要 provider(模型适配层):

Provider(模型适配层) 文件 说明
Claude cli/providers/claude.py Claude tool_use / tool_result 双向转换及滑动 Prompt 缓存
OpenAI-compatible(OpenAI 兼容接口) cli/providers/openai.py OpenAI、DeepSeek、Qwen、Kimi 等兼容端点
Gemini cli/providers/gemini.py Gemini function calling 适配
Fallback(故障切换) cli/providers/fallback.py 限流、超时、服务端错误时切换备用 provider(模型适配层)

OpenAI-compatible provider(OpenAI 兼容模型适配层)有三类兼容处理:

  1. 首次调用携带 stream_optionstool_choicefrequency_penalty
  2. 第三方端点不支持时逐步去掉不兼容参数。
  3. 对把工具调用输出成 <tool_call>...</tool_call> 文本的模型做兜底解析。

流式读取由 _iter_with_timeout() 包装:生产者 daemon thread(守护线程)负责读 stream(流式响应),主线程从 queue(队列)取 chunk(流式片段);60 秒无新 chunk 就抛出超时,避免 TCP 半开连接把 TUI 卡死。

模型元数据由 cli/model_registry.pycli/model_metadata.py 维护,覆盖 context window(上下文窗口)、reasoning(推理能力)和成本显示:

  • wyckoff model cost <id> --context-window N 可以显式保存模型上下文窗口。
  • 未显式配置时,infer_context_window() 会按模型名推断常见窗口;未知模型默认按 64K token(令牌)处理。
  • FallbackProvider(故障切换模型适配层)按默认模型和 fallback(备用模型)顺序尝试,只在限流、超时、网络错误和服务端错误等可恢复异常上切换;配置错误不会被静默吞掉。

3. 工具注册与调度

CLI(命令行)工具系统在 cli/tools.py

  • TOOL_SCHEMAS:给模型看的 JSON Schema(JSON 格式工具参数协议)。
  • ToolSpec:工具元数据,包括中文展示名、是否并发安全、是否需要确认、是否后台执行。
  • ToolRegistry:工具注册表,负责 provider(模型适配层)/ context(上下文)注入、确认回调、后台任务提交和实际执行。
  • ToolContext:工具上下文,跨工具共享 stateproviderregistryon_progress,也是 sub-agent(子智能体)委派的桥。

CLI(命令行)当前注册 21 个工具:

类型 工具
金融分析 search_stock_by_nameanalyze_stockportfolioget_market_overviewget_market_historyscreen_stocksgenerate_ai_reportgenerate_strategy_decisionquery_historyrun_backtest
数据修改 update_portfolio
后台任务 check_background_tasks
交互 / Skill(技能) ask_userexecute_skill
Sub-agent(子智能体)委派 delegate_to_researchdelegate_to_analysisdelegate_to_trading
本地工具 exec_commandread_filewrite_fileweb_fetch

调度策略:

  • concurrency_safe=True(并发安全)的工具可在同一轮连续工具调用中并行执行,最大 ThreadPoolExecutor(max_workers=5)(5 线程线程池)。
  • 非并发安全工具串行执行,避免写操作、后台任务或外部副作用互相踩踏。
  • requires_approval=True(需要用户审批)的工具在 TUI 中弹确认框:允许一次、总是允许、修改后执行或拒绝。
  • background=True(后台执行)且 TUI 注入了 BackgroundTaskManager(后台任务管理器)时,工具立即返回 task_id(任务编号),实际任务在 daemon thread(守护线程)中运行。

当前并发安全工具:

search_stock_by_nameanalyze_stockportfolioget_market_overviewget_market_historyquery_historyexecute_skill

当前高风险确认工具:

update_portfolioexec_commandwrite_file

高风险二次确认与 ask_user

为了防止模型越权执行高风险工具,系统设计了双重确认拦截机制

  1. 静默拦截:若 requires_approval=True 且未配置 confirm 回调时,ToolRegistry 会直接阻断调用,告知模型必须先调用 ask_user 解释风险并获取同意。
  2. 上下文确认检索ToolRegistry.execute 会在每轮调用前扫描 messages 对话历史。只有当在历史中检索到由 ask_user 返回的“用户同意”(如 "确认"、"继续"、"yes"、"allow")时,该高风险工具才会被放行执行。这有效防止了模型跳过询问直接操作真实仓位。

当前后台工具:

screen_stocksgenerate_ai_reportgenerate_strategy_decisionrun_backtest


4. 上下文管理与压缩 (Context Compaction)

上下文压缩逻辑实现在 compaction.py,由终端 TUI 和无界面运行时(Headless Runtime)共用。其主要任务是在对话不断延长、Token 即将溢出时,自动压缩历史对话,并在不破坏最新工具调用上下文的同时释放上下文空间。

4.1 触发判定与阈值计算 (Trigger & Threshold)

为了防止多轮 ReAct 循环或大型工具数据爆发(Burst)时瞬间撑爆上下文,系统采用了保守的**动态预留安全垫(Safety Reserve)**策略:

  • 上下文窗口推断 (Context Window):优先读取用户通过 wyckoff model cost 显式配置的窗口值。若未配置,则通过 infer_context_window() 按模型名称映射默认窗口大小(如 Claude 为 200K、Gemini 为 1M、DeepSeek 为 64K)。

  • 安全垫预留公式 (Reserve Budget): 安全垫的设立是为了给大模型的回复内容、系统提示词、工具定义以及下一轮工具调用的临时返回数据预留出充足的空白空间。如果把上下文塞到 100% 满才压缩,大模型将直接因为“没有空间说话”而报错中断。

    安全垫的估算公式(使用 0.25 代替 % 符号以避免 GitHub 渲染解析报错): $$\text{Reserve} = \min\left(\max(16384, \min(\text{ContextWindow} \times 0.25, 32768)), \frac{\text{ContextWindow}}{2}\right)$$

    通俗理解这个公式

    1. 我们期望在上下文窗口中,预留 25% 的空间不放历史记录。
    2. 为了防止小窗口模型的 25% 空间太少,系统规定了安全垫的硬性最低保底值为 16,384
    3. 为了防止极大型窗口模型(如 Gemini 的 1M 窗口)预留的 25% 空间过大造成浪费(250K),安全垫的最高上限硬性限制在 32,768
    4. 同时,为了防止在极小窗口模型中安全垫过大,安全垫的最高上限绝不能超过窗口大小的一半
  • 压缩触发条件 (Compaction Trigger): 系统使用 estimate_tokens() 实时估算当前整个对话队列占用的空间。一旦当前对话体积超过了 “触发阈值($\text{ContextWindow} - \text{Reserve}$)”,系统就会判定“空间不够了”,立刻启动上下文压缩管线。

模型类别 窗口容量 (Context Window) 预留缓冲 (Reserve) 压缩触发阈值 (Threshold)
DeepSeek 64K 16.4K 47.6K
GPT / 兼容端点常见模型 128K 32K 96K
Claude 200K 32.8K 167.2K
Gemini 2 1M 32.8K 967.2K

4.2 双向切分与工具链依赖保护 (Split & Tail Alignment)

压缩发生时,系统会将 messages 划分为陈旧头历史 head 与最新保留尾历史 tail

  1. Token 倒序扫描定位: 系统调用 find_tail_start_by_token_budget 从最近的一条消息倒序向前扫描,累加 Token 长度,直到其大小满足保留预算 keep_recent_tokens(默认保留最近的 20K,至少保留 TAIL_KEEP = 4 轮)。
  2. 工具返回消息修正: 如果切分出来的首条消息其 role == "tool",系统会自动向前滑动移动指针,避免 tail 队列从中间截断工具结果。
  3. 前置工具调用追溯对齐 (_expand_tail_for_tool_refs): 对于所有被划分在 tail 内部的 tool 返回消息,系统会检查其关联的 tool_call_id。如果发起该调用的 assistant 消息被分到了 head,系统会强行向左拓宽 tail 的边界,将该 assistant 消息拉入 tail 范围中。这彻底避免了“孤儿工具返回结果”导致的 API 语法协议报错。

4.3 动态保留与可恢复压缩管线 (Dynamic & Recoverable Compaction)

flowchart TD
    A["1. 检测到估算 Token 超出 Threshold"] --> B["2. 按 Token 预算倒序截出 tail"]
    B --> C["3. 运行 _expand_tail_for_tool_refs 对齐工具链"]
    C --> D["4. 对 head 做动态重要性评分"]
    D --> E["5. 选出动态保留片段 anchors"]
    E --> F["6. 复制 head 后执行 shrink_stale_tool_results"]
    F --> G["7. flush_memory_before_compaction 提取长期偏好"]
    G --> H["8. LLM 生成 500 字摘要"]
    H --> I["9. create_context_archive 写入原文归档"]
    I --> J["10. 重组 messages:摘要 + anchors + archive 引用 + tail"]
Loading

旧版本的压缩主要依赖“保留最近一段 tail”。当前实现改成两层:tail 保留最近工作现场,anchors 保留旧历史中的高价值片段。高价值片段由 score_message_importance()select_anchor_messages() 计算,评分依据包括:

评分信号 目的
用户消息 用户约束和目标通常比助手复述更重要
股票代码 单股问题必须保留明确实体,例如 603373301348
文件路径 代码修改、review、bug 定位需要保留具体文件
报错/失败/提交/回测/持仓/建仓等关键词 这些通常代表未完成任务、风险点或可执行决策
工具调用和工具结果 保留证据链,避免摘要只剩结论

这不是把所有旧消息继续塞回上下文,而是把最可能影响后续执行的几条原文短片段放进压缩摘要中的 [动态保留片段]。因此,后续简短追问仍能看到关键股票、关键文件和错误现场。

阶段一:陈旧工具结果预裁剪 (Compaction Input Pruning)

工具 observation(观察结果)一旦写入 messages,常规 agent loop(智能体循环)不会回头改写它;这样可以保证历史消息前缀稳定,提升 prompt cache(提示词缓存)命中率,也避免模型前后看到的证据链不一致。

只有在上下文确实超过阈值、即将执行整体 compaction(压缩)时,系统才会复制 head,并在这份压缩输入副本上针对长度超过 SHRINK_THRESHOLD (800字符) 的旧轮次工具结果运行 shrink_stale_tool_results()

  • analyze_stock 这种海量实时 JSON,调用 _summarize_tool_result() 提取核心字段(代码、简称、健康状态分类、最新的 5 根日线数据),过滤其他详细调试字段。
  • 将大型 JSON 在压缩输入副本中转化为 400-600 字符的简易摘要体,在送给 LLM 前先对 head 自身的 Token 长度进行清洗,大幅降低摘要 LLM 的调用开销。
  • 如果 compaction(压缩)失败,原始 messages 不会被这一步污染。

阶段二:压缩前记忆刷写 (Memory Flush)

为避免在 LLM 生成大段摘要的过程中遗失用户的强个性化特征,在 head 消息彻底被截断前,调用 flush_memory_before_compaction()

  • 提取 head 中属于 user / assistant 角色的对话片段。
  • 调用 LLM 并使用专属的 _FLUSH_PROMPT,专门提取用户的稳定投资偏好、仓位/止损风险偏好、重点标的长期结论
  • 提取出的记录被当场写入本地 SQLite 的 agent_memory 数据库(归类为 preference),使其转化为长期记忆,脱离会话上下文的生命周期。

阶段三:LLM 摘要生成与重组 (LLM Summarization & Assembly)

  • 序列化:通过 serialize_messages_for_compaction()head 转化为纯文本,并将工具交互转换为结构化标记(如 [tool:analyze_stock] ... )。
  • LLM 总结:使用专属的 COMPACTION_PROMPT 总结,控制在 500 字以内,着重提取用户的意图、已完成的动作和股票关键结论。
  • 原文归档:摘要生成成功后,调用 create_context_archive() 将被压缩的 head 原文写入 ~/.wyckoff/context_archive/<session_id>/<compaction_id>.jsonl,同时写入一个 JSON metadata 文件,记录摘要、股票代码、文件路径、关键词、anchors 和 archive://... 引用。
  • 重组:生成完毕后,原 messages 被替换为一条带 [对话摘要][动态保留片段][可恢复归档] 的首发 user 消息与一条接续的 assistant 消息,再无缝衔接上完全没有受损的 tail 队列。
# 压缩后最终 messages 结构
messages = [
    {"role": "user", "content": "[对话摘要]\n...\n[动态保留片段]\n...\n[可恢复归档]\n- archive://session/ctx_xxx"},
    {"role": "assistant", "content": "好的,我已了解之前的对话上下文,请继续。"}
] + tail

4.4 信息保真与可回溯边界

上下文压缩摘要本身仍然是有损的,但系统不再把“摘要”当成唯一历史来源。现在采用 摘要继续执行 + 原文归档恢复 的双轨策略:

风险 保护机制
最新任务、最新约束被压掉 tail 按 Token 预算保留最近原文,且至少保留最近 4 条消息
工具调用链被截断 _expand_tail_for_tool_refs() 会把 tail 中工具结果对应的 assistant 工具调用一起拉回
旧历史中的关键股票/文件被摘要淡化 select_anchor_messages() 按重要性评分选出动态保留片段
大型工具 JSON 淹没摘要 shrink_stale_tool_results() 只在压缩输入副本上做结构化裁剪,保留股票代码、最新价格、健康状态、信号和关键结论
用户长期偏好被摘要吞掉 flush_memory_before_compaction() 在截断 head 前提取稳定偏好和长期结论,写入 agent_memory
LLM 摘要失败或质量过低 摘要为空或过短时直接放弃压缩,继续使用原始 messages
事后需要复盘原始证据 context_archive 保存被压缩原文,scratchpad 记录 archive_ref 和压缩事件

所以压缩后的上下文由三部分共同保证连续性:

  1. 当前工作状态:最近 tail 原文继续留在模型上下文里。
  2. 动态关键片段:被压缩 head 中最重要的股票、文件、报错和用户约束以 anchors 形式保留。
  3. 长期稳定信息:偏好、风控习惯、长期标的结论进入记忆系统。
  4. 原始运行证据:context archive、scratchpad 和 chat log 负责审计与回溯。

后续用户提到相关股票、文件、关键词或 archive://... 引用时,build_memory_context() 会通过 archive_recall_lines() 把命中的压缩归档索引注入当前轮。需要完整原文时,可以通过 restore_context_archive()archive_ref 找回被压缩消息。这样模型平时只携带轻量摘要,需要证据时再恢复原文。


4.5 前端/网页端轻量化摘要实现

由于 Web 端(前端面板)没有常驻的 Python 运行时,它使用 TypeScript 实现了一套确定性的轻量化压缩方案:

  • prepareChatMessagesForModel() 中,计算同样的安全预留缓冲并评估 Token。
  • 触发时,不再调用 LLM,而是直接由前端代码对历史消息中的股票代码、用户近期提问、Assistant 主要结论进行静态拼接,提取出一段确定性的 [读盘室对话摘要] 文本替换头部,同样实现了对 Token 窗口的保底控制。

5. 记忆系统

记忆系统在 cli/memory.py,只服务 CLI(命令行)/ TUI(终端图形界面)。

它不是聊天记录归档,也不是持仓数据库。它的目的只有一个:让后续交易判断持续遵守用户已经确认过的交易纪律,例如不追涨、偏好尾盘二次确认、跌破确认支撑不买、极端放量冲高回落不买。

因此,记忆系统只沉淀稳定、可复用的信息;每天行情、当前持仓、某只股票今天能不能买、工具调用细节,都不应该进入长期记忆。这些内容应该来自实时工具、推荐表、持仓表、信号表或 chat log。

5.1 分层语义:L1 / L2 / L3 不是优先级

L1、L2、L3 表示信息的抽象方式,不表示谁比谁“更高级”、也不表示 L3 一定覆盖 L2。

flowchart TD
    L1["L1 原子记忆<br/>偏好 / 决策"] --> L2["L2 交易剧本<br/>什么场景下怎么做"]
    L1 --> L3["L3 用户画像<br/>这个用户长期是什么交易风格"]
Loading
层级 存储类型 解决的问题 典型内容 不该放什么
L1 preference / decision 记录原始证据 “用户偏好尾盘买入”;“跌破确认支撑不买” 今日行情、一次性买卖、工具执行过程
L2 playbook 把多个 L1 归纳成可执行流程 “尾盘二次确认买入剧本:14:45 后检查支撑、VWAP、量能;破支撑不买” 泛泛的投资理念、没有触发条件的口号
L3 persona 把多个 L1 归纳成用户画像 “用户重视本金安全,偏好二次确认,不喜欢开盘交易影响情绪” 临时市场观点、某只股票当前结论

三者的使用方式不同:

  • L1 是证据:回答“这条规则从哪里来”。
  • L2 是动作:回答“遇到这个交易场景应该怎么执行”。
  • L3 是约束:回答“这个用户整体是什么风险偏好和交易性格”。

举例:

L1:
- [preference] 用户不想每天开盘买,容易影响心情
- [preference] 用户倾向尾盘买入
- [decision] 当天跌破确认支撑不买
- [decision] 极端放量但冲高回落不买

L2:
- [playbook] 尾盘二次确认买入:适用于已进入候选池且需要二次确认的股票;14:45 后检查支撑、VWAP、收盘位置和量能;跌破确认支撑或极端放量冲高回落时禁止买入。

L3:
- [persona] 用户重视本金安全和交易情绪稳定,偏好二次确认,不喜欢开盘冲动交易。

所以,L2 和 L3 是从同一批 L1 派生出的两个维度:L2 面向交易动作,L3 面向用户画像

5.2 写入流程:先存 L1,再蒸馏 L2/L3

L1 的生成:会话摘要提取

对话结束或新开会话时,save_session_summary() 在后台线程异步执行。触发前先做轻量门控,任一不满足则跳过:

  • 消息总数 ≥ 4 条
  • 本轮对话有工具调用(纯聊天不值得提取)
  • provider(模型适配层)已配置
  • 当前 session(会话)的摘要 hash(哈希)没有处理过;如果 /new、resume(恢复会话)、fork(分叉会话)或退出重复触发同一批消息,直接跳过,不再请求 LLM

满足条件后,系统会把对话消息转换成摘要输入文本,再发给 LLM 用 _SESSION_SUMMARY_PROMPT 提取。摘要输入不是直接取原始 messages[-40:],而是先遍历全部消息,将非空 content 格式化成 [role] content 文本行;其中 role=tool 的工具结果如果超过 200 字,会先截断到 200 字。最后取这些文本行的最后 40 行。只有 tool_calls、没有 content 的 assistant 消息不会进入摘要输入。

Prompt 只允许输出两种格式:

[偏好] 用户偏好尾盘二次确认,不想开盘买入影响情绪
[决策] 当天跌破确认支撑不买

解析后按标签写入 SQLite:[偏好]preference(L1),[决策]decision(L1)。无论提取结果是否写入新 L1,系统都会写入一个 session marker(处理标记),记录 summary_hash,用于避免同一批消息重复摘要;这个 marker 不参与召回注入。

如果一条 L1 内容自身包含 6 位代码或明确股票名称,写入前会用本地股票名称缓存解析成 codes,用于后续确定性召回;没有明确股票的全局交易纪律保持 codes=''。这样可以避免“本轮聊过宁德时代,所以不追涨这条全局偏好也被误绑到宁德时代”。

写入前经过两级去重。第一层是 确定性去重:对同类型已有记忆做规范化文本比较和字符 bigram(双字片段)相似度判断,完全重复或高度相似时直接跳过,不调用 LLM。

第二层是 LLM 语义去重:当同类型已有记忆存在,且确定性规则未判定重复时,系统会使用 fallback provider(不可用时复用当前 provider)把新内容和最近 10 条同类型记忆一起发给 _DEDUP_PROMPT 判断。只有明确判定为 NEW 的内容才写入 L1;如果语义去重调用失败、返回格式无效,或返回了不存在的重复记忆 id,系统会跳过本条写入,避免未经确认的重复记忆进入长期记忆。

L2 / L3 的生成:批量蒸馏,不是单条晋升

每次成功写入至少一条新 L1,且 skip_layers=False 时,会尝试触发 refresh_memory_layers()。触发不等于一定调用 LLM,系统先做增量门控:

atoms = get_recent_L1_preference_and_decision(limit=30)

if len(atoms) < 3:
    return 0

source_hash = hash(atoms)
if source_hash 已经被 L2/L3 记录过:
    return 0

if 不是首次蒸馏 and 新增 L1 数量 < 3:
    return 0

_LAYER_REFRESH_PROMPT 要求 LLM 基于最近 L1 归纳两类高层记忆,每类最多 3 条:

[画像] 用户重视本金安全和情绪稳定,偏好尾盘二次确认
[剧本] 尾盘二次确认买入:适用于已进入候选池的股票;14:45 后检查支撑、VWAP、收盘位置和量能;跌破确认支撑或极端放量冲高回落时禁止买入

解析标签后写入:[画像]persona(L3),[剧本] / [交易剧本]playbook(L2)。历史 scenario 记录仅做兼容召回;新生成的 L2 统一写入 playbook

每条由层级蒸馏生成的 L2/L3 都会在 metadata 中记录:

  • extractor=layer_refresh
  • layer_version
  • source_l1_ids
  • source_hash

关键设计:L2/L3 不是“某条 L1 被升级”,而是“最近一批 L1 被重新归纳”。原始 L1 仍然保留,L2/L3 只是在它们之上生成可复用摘要。系统每次蒸馏仍读取最近 30 条 L1 以保留全局语境,但只有当 source hash 未处理过,且距离上次蒸馏至少新增 3 条 L1 时才真正调用 LLM。这样避免同一批 L1 被反复重算,也降低 L2/L3 被同义改写污染的概率。

完整生命周期

flowchart TD
    A["对话结束 / 新会话"] --> B{"消息 ≥ 4 条\n且有工具调用"}
    B -->|否| Z["跳过"]
    B -->|是| H0{"session summary_hash\n是否已处理"}
    H0 -->|是| Z
    H0 -->|否| C["取最近 40 条消息\n工具结果截断至 200 字"]
    C --> D["LLM 提取\n最多 3 条 [偏好] / [决策]"]
    D --> E{"确定性去重\n规范化文本 / bigram 相似度"}
    E -->|重复| M["写 session marker"]
    E -->|新内容| E2{"同类型已有记忆"}
    E2 -->|否| F["写入 SQLite\nL1 preference / decision"]
    E2 -->|是| E3{"LLM 语义去重\n与已有 10 条对比"}
    E3 -->|重复或失败| M
    E3 -->|NEW| F
    F --> M
    M --> G{"是否退出场景\nskip_layers"}
    G -->|是| Z
    G -->|否| H{"L1 总数 ≥ 3 条"}
    H -->|否| Z
    H -->|是| I{"source_hash 是否处理过\n或新增 L1 不足 3 条"}
    I -->|是| Z
    I -->|否| J["取最近 30 条 L1\n发给 LLM 蒸馏"]
    J --> K["LLM 输出 [画像] / [剧本]"]
    K --> L2["L2 playbook\n条件化交易剧本"]
    K --> L3["L3 persona\n用户稳定画像"]
    L2 --> W["写入 SQLite\nmetadata 记录 source_l1_ids / source_hash"]
    L3 --> W
Loading

容量上限与老化清理

类型 上限 清理策略
preference 50 条 超出后删最旧;永不因时间衰减降权;长期保留
decision 30 条 超出后删最旧;45 天后被 prune_memories 清理
playbook 20 条 超出后删最旧;60 天后清理
persona 5 条 超出后删最旧;永久保留,不受时间清理影响
scenario 兼容类型 旧版本 L2 记录,按 playbook 规则召回和清理

5.3 召回管道设计

召回入口流程总览:

flowchart TD
    U["当前用户消息"] --> C["解析股票代码 / 股票名称"]
    U --> K["抽取中文关键词"]
    U --> F["FTS5 全文检索"]
    C --> S["代码精确匹配"]
    K --> L["关键词 LIKE 匹配"]
    F --> R["混合排序 + 类型半衰期"]
    S --> R
    L --> R
    R --> P["Persona / Preference 置顶"]
    P --> D["按 id 去重"]
    D --> I["注入 relevant-memories"]
Loading

召回由 build_memory_context()cli/memory.py)驱动,底层实现在 search_memory_hybrid()integrations/local_db.py)。每次用户发送消息,系统先把 6 位代码和本地股票名称解析成确定性 codes,再启动三路独立检索管道,结果合并后经股票作用域过滤和时间衰减加权排序。

股票作用域过滤是防止错召的关键:

  • 当前问题能解析出明确股票时,只保留全局记忆和同代码记忆。例如“我想建仓比亚迪”会解析到 002594,不会召回 300750 宁德时代的单股建仓记忆。
  • 当前问题没有明确股票时,带 codes 的单股记忆不参与召回。例如“我想建仓科技”不会因为“科技”两个字召回“长信科技”的单股记忆。
  • 科技白酒 这类词属于板块/主题,不等同于股票名;它们可以作为未来主题记忆的实体,但不能靠股票代码确定性召回。

三路检索管道

管道一:FTS5 全文检索(权重 0.8)

将用户原始消息直接提交给 SQLite FTS5 虚拟表:

SELECT m.*, bm25(agent_memory_fts) AS rank
FROM agent_memory_fts fts
JOIN agent_memory m ON m.id = fts.rowid
WHERE agent_memory_fts MATCH '用户输入'
ORDER BY rank

FTS5 使用 SQLite 默认 unicode61 tokenizer 建立倒排索引后用 BM25 排序。BM25 同时考虑词频(TF)和逆文档频率(IDF),比单纯 LIKE 匹配更精准,也能容忍部分字段不完整匹配;中文短词覆盖主要由后面的关键词 2-gram 管道兜底。

管道二:股票实体精确匹配(权重 1.2)

正则 (?<!\d)(\d{6})(?!\d) 从用户输入中抽取 6 位股票代码,同时使用本地 stock_list_cache.json 做股票名到代码的最长匹配。例如“宁德时代”映射为 300750,“比亚迪”映射为 002594,然后对 codes 字段做 LIKE 匹配:

WHERE codes LIKE '%300750%'

代码命中说明这条记忆明确与当前股票相关。FTS5 和关键词召回得到的候选也会经过同样的代码作用域过滤,避免同一个“建仓”泛词把其它股票的历史记忆带进来。

管道三:中文关键词 LIKE 匹配(权重 0.25)

_extract_keywords() 对用户输入做轻量分词:

  1. 正则 [一-鿿]{2,4} 抓出所有 2~4 字中文片段。
  2. 超过 2 字的片段按 2-gram 滑窗拆分("建仓位置"→"建仓"+"仓位"+"位置"),提高短词召回率。
  3. 过滤停用词和泛交易词("可以"、"现在"、"建仓"、"买入"、"止损" 等)。
  4. 去重,取前 5 个关键词。

对每个关键词做 content LIKE %keyword%,多词 OR 合并查询,命中任意一词即得分。权重最低,用于兜底覆盖 FTS5 未建索引的边角情况。

结果合并:取最高分,不叠加

candidates: dict[int, dict] = {}   # key = 记忆 id

def _merge(items, source_weight):
    for m in items:
        if m["id"] not in candidates:
            m["_score"] = source_weight
            candidates[m["id"]] = m
        else:
            candidates[m["id"]]["_score"] = max(已有分, source_weight)

同一条记忆被多个管道命中,得分取三者中的最大值,不叠加。这样避免"被多个低质量管道重复命中的普通记忆"压过"只被 FTS5 精准命中的高质量记忆"。

时间衰减(按类型缩短半衰期)

所有候选记忆都乘以时间衰减系数,默认半衰期 14 天;L2 交易剧本稍长,L3 画像和长期偏好不衰减:

$$\mathrm{finalScore} = \mathrm{baseScore} \times 2^{-\mathrm{ageDays}/\mathrm{halfLifeDays}}$$

类型 半衰期 自动清理
decision 14 天 45 天
playbook / scenario 21 天 60 天
stock_opinion / market_view / fact 14 天 30-45 天
preference / persona 不衰减 长期保留

这样做的目的,是让阶段性单股判断和交易剧本更快让位于新反馈;用户的长期禁忌和风险边界仍然稳定保留。

什么内容应该进记忆

记忆系统的判断标准不是“这句话重要不重要”,而是“它以后是否能稳定改善交易决策”。

信息 是否进记忆 推荐落点
“我不想开盘买,影响心情” L1 preference,后续可蒸馏进 L3 persona
“跌破确认支撑不买” L1 decision,后续可蒸馏进 L2 playbook
“尾盘二次确认后再买” L1 preference / L2 playbook
“今天泛微网络可以买吗” 当前工具分析 / chat log
“我现在持有 4 只股票” portfolio / Supabase 持仓表
“今天市场资金撤退明显” 市场数据表 / 当日报告
“某次工具调用失败” agent log / scratchpad

阶段性观点与冲突记忆

投资场景里有一类信息变化很快:今天认为黄金有避险价值,明天觉得黄金逻辑失效、科技主线更强,后天又认为白酒长期下跌后有修复机会。这类内容不能简单当成长期画像覆盖,也不能只保留最后一句,否则会丢失用户决策风格中的“切换条件”。

当前实现采用追加式记忆 + 当前轮召回,而不是覆盖式记忆:

  1. 临时交易指令默认不沉淀_SESSION_SUMMARY_PROMPT 明确要求不要提取具体买卖事实、临时操作和当天市场状态。
  2. 阶段性判断优先落在 L1 decision:如果用户表达的是“因为某个逻辑失效,所以从黄金切到科技”,这更像决策逻辑,而不是永久偏好。
  3. 稳定风格才进入 preference / persona:例如“不追涨”、“重视止损”、“单票仓位不超过 15%”,才适合长期置顶。
  4. 旧判断不自动删除:系统保留 created_atsource_ref,让模型看到用户观点变化的时间顺序,也能用 wyckoff memory trace <id> 回看来源。
  5. 召回只作为参考:记忆被包在 <relevant-memories> 中,并声明“不代表当前任务进程,仅作为参考”,当前问题和工具实时数据仍然优先。

因此,黄金、科技、白酒这类主线切换更适合作为阶段性 decision 或 L2 playbook 的触发条件被召回,而不是永久 persona。当前系统能保留变化过程和来源证据,但还没有显式的 superseded(被新观点取代)状态;如果后续要更严格处理冲突,可以在 agent_memory.metadata 中增加 topic_keyvalid_untilsuperseded_by 等字段,用于把同一主题下的旧观点标记为已被覆盖。

Persona / Preference 置顶,Playbook 相关召回

persona(L3)和 preference(L1)还额外走一条旁路,不参与 hybrid search 的分数竞争,直接按时间取最新的(persona 取 1 条,preference 取 5 条),在最终组装时强制排在最前。

注意这里不是 L3 全量召回:当前只无条件置顶最新 1 条 personaplaybook(L2)走 hybrid search,只有和当前 query 相关时才进入“# 交易剧本”。

置顶后的 persona / preference 会按 memory id 从 hybrid 结果里去重,避免同一条偏好既出现在"# 用户画像",又出现在"# 历史记忆":

# 用户画像(persona + preference,置顶)
# 交易剧本(playbook / legacy scenario,hybrid search 命中,最多 3 条)
# 历史记忆(decision 等,hybrid search 命中)

总量控制

全部内容组装后经 _budget_recall_lines() 做 token 预算截断:每条记忆不超过 200 字符,所有召回内容合计不超过 1200 字符。超出时从末尾截断,保证注入体积不会对上下文窗口造成压力。

注入格式通过 prepend_memory_context() 把召回记忆包在 <relevant-memories>(相关记忆)中,再把当前用户消息包在 <current-user-message>(当前用户消息)中。这样模型可以参考长期偏好,但不会把记忆误当成当前任务进度。

5.4 记忆注入机制与缓存命中

记忆召回的注入采用**瞬态注入(Transient Injection)**模式:注入→发送→还原,只活在当前轮,不污染历史消息。

完整三步流程

第一步:构建并暂存(_send_messagetui.py

用户发出消息后,先调用 build_memory_context(text) 做 hybrid search 召回记忆,结果暂存在私有字段 _memory_context 里,此时 messages[-1]["content"] 仍是原始用户输入:

user_message = {"role": "user", "content": text}
if mem_ctx:
    user_message["_memory_context"] = mem_ctx  # 暂存,不直接污染 content
self._messages.append(user_message)

第二步:发送前注入(_prepare_turn_memory_contexttui.py

_run_agent() 启动时立即调用,把记忆从 _memory_context 弹出并 prepend 到 content 前,原始文本备份到 _raw_content

self._messages[turn_index]["_raw_content"] = user_text          # 备份原文
self._messages[turn_index]["content"] = prepend_memory_context(user_text, memory_context)

实际发给模型的 content 格式如下:

<relevant-memories>
以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:

# 用户画像
- [persona] 用户重视本金安全和情绪稳定,偏好尾盘二次确认
- [preference] 用户不想开盘买入,避免影响一天心情

# 交易剧本
- [playbook] 尾盘二次确认买入:14:45 后检查支撑、VWAP、收盘位置和量能;破支撑不买

# 历史记忆
- [decision] 极端放量但冲高回落不买
</relevant-memories>

<current-user-message>
宁德时代现在可以建仓吗
</current-user-message>

第三步:回复后还原(_restore_turn_user_messagetui.py

模型回复结束后,把 content 从注入版本恢复为原始用户输入:

msg["content"] = msg.pop("_raw_content")   # 擦除注入,还原原文

为什么不注入到 System Prompt?

方案 问题
改 System Prompt 记忆持续占用所有后续轮次 token;每轮重建 system prompt 会破坏静态基底缓存
Prepend 到当前轮 user message(当前实现) 只影响当前轮;用后擦除;记忆与当前问题绑定,模型关联度更高

与 Prompt Caching 的关系

Section 2 描述的滑动提示词缓存依赖前缀稳定性:只有发给 API 的 messages 前缀与上一次请求完全一致,才能命中缓存。

还原后,每轮发出的历史消息结构如下:

第 N+1 轮发出的 messages(还原后):
system | u1 | a1 | u2 | a2 | ... | u(N-1) | a(N-1) | uN(还原后) | aN | uN+1+记忆
←────────── 全部命中第 N 轮的缓存 ──────────────────→ ↑miss(因为缓存里是 uN+记忆)

每轮固定只 miss 上一轮的最后一条 user message(因为那条发出时带了注入的记忆,还原后变成干净原文,与缓存不一致)。其余所有更早的历史消息前缀完全稳定,全部命中缓存。

对话轮次 N 历史消息总数 每轮 miss 条数 miss 比例
2 2 1 50%(短对话损失大)
5 8 1 12.5%
10 18 1 5.6%
20 38 1 2.6%(趋近于零)

缓存 miss 的代价是固定 O(1) 的,不随对话增长而扩大。

与不还原相比(记忆永久残留在历史里):不还原时历史消息同样稳定,缓存命中率反而略好,但代价是每轮记忆文本(~500 token)持续堆积在 context 里,20 轮后额外占用 10,000+ token。

结论:「用后擦除」的核心价值是防止上下文无限膨胀,同时把缓存 miss 的代价锁定在固定 1 条,不让脏历史随轮次扩散。缓存方面不是零损耗,但随对话增长趋近无影响。

每条记忆保留 source_ref=chat_log:<session_id>(来源引用)。CLI(命令行)可用 wyckoff memory trace <id> 回看来源,避免长期摘要变成不可验证黑盒。

注意:agent_memorycontext_archive 是两套不同机制。agent_memory 只保存可跨会话复用的长期偏好、交易纪律和决策剧本;context_archive 保存被上下文压缩移出主窗口的原始消息,用于同一类问题的证据恢复。前者用于“以后怎么做得更符合用户”,后者用于“刚才/之前具体发生了什么还能找回来”。

5.5 与结构化数据的分工

记忆系统只负责“用户长期怎么交易”,不负责“今天发生了什么”。交易系统里的事实数据应继续留在结构化表和工具返回中:

内容 正确来源
当前持仓、成本、盈亏 portfolio / Supabase 持仓表
今日推荐、AI 是否推荐、二次确认原因 recommendation / signal 表
今日市场状态、资金趋势、板块水温 每日漏斗报告 / 市场数据工具
某只股票今日 K 线和分钟线 TickFlow / Tushare / 行情工具
Agent 当时怎么想、调用了什么工具 scratchpad / chat log / agent log
被上下文压缩移出的原始消息 context archive / archive://...
用户稳定偏好、交易禁忌、可复用执行剧本 agent_memory

这个边界很重要:如果把短期行情塞进长期记忆,后续模型会把过期事实误当成当前事实;如果把交易纪律只放在一次对话里,后续又会忘记用户的风控习惯。


6. Loop Guard(循环守卫)与死循环保护

模型偶尔会只输出计划、不调用工具。cli/loop_guard.py 把部分数据型任务从 prompt(提示词)约束提升为运行时约束。

6.1 必须工具识别机制

运行时会通过 resolve_turn_expectation(messages) 判断当前对话轮次是否必须调用某个工具(目前主要针对 portfolio 即持仓数据工具进行强制要求)。具体识别逻辑如下:

1. 意图匹配场景

系统定义了多组硬编码的关键词元组,通过在归一化(转小写、去除首尾空格)后的用户输入中检索子串进行意图推断:

  • 直接查询持仓(View Portfolio):用户输入中包含 "我有什么持仓", "我买了啥", "持仓情况" 等关键词。强制要求调用 portfolio(mode="view")
  • 直接持仓诊断(Diagnose Portfolio):用户输入中包含 "我持仓怎么样", "持仓健康吗", "帮我审一下持仓" 等关键词。强制要求调用 portfolio(mode="diagnose")
  • 上下文承接诊断(Contextual Follow-up):用户输入为简短词(如 "体检", "健康吗")或带有指代关系(如 "分析这些", "这几只"),且在最近 4 条上下文消息中检测到了持仓标记(如 "持仓", "成本价", "代码 | 名称 | 持股" 类似字样)。强制要求调用 portfolio(mode="diagnose")
  • 肯定答复承接诊断(Affirmative Response):用户输入为肯定词(如 "要", "好的", "可以", "行"),且在最近 4 条上下文消息中包含体检/分析的暗示及持仓标记。强制要求调用 portfolio(mode="diagnose")

2. 上下文判定范围与隔离性

  • 匹配范围:所谓的“最近 4 条上下文消息”(代码中为 messages[:-1] 并设定 limit=4),指的是当前对话会话(Session)中排除当前用户输入后,往前倒数的 4 个消息对象(包含 userassistanttool 执行结果,并非真正的 4 轮完整对话)。
  • 会话内累积:在同一个会话(Session)中,用户的 Query 和助手的回复是不隔离的。历史消息不断累积追加到上下文列表,因此后续的简短提问可以通过 previous_context 关联上前面的对话环境(例如检测到刚才助手展示的持仓表格特征)。
  • 会话间隔离:不同会话(或清除历史、重启终端)之间是完全隔离的。上下文重置为零,匹配逻辑无法跨会话追溯。

3. 剥离记忆文本干扰

为了防止系统在前序处理中自动召回并注入到用户消息中的长期记忆(包含在 <relevant-memories> 标签内)误触发敏感词匹配,匹配引擎在进行子串检索前会通过 _strip_recall_context() 函数强行剥离记忆区域的文本,只留下用户手打输入的原始 Query(包含在 <current-user-message> 内)进行判定。

6.2 拦截与纠偏机制

如果模型漏调必需工具,runtime(运行时)会注入 retry user message(纠偏用户消息),最多重试 2 次;仍失败则把警告前置到最终回答里。

6.3 运行阈值与死循环检测

当前实际阈值:

常量 当前值 含义
MAX_TOOL_ROUNDS 15 单轮用户请求最多 15 轮模型-工具往返
MAX_INCOMPLETE_TOOL_RETRIES 2 漏调必需工具时最多纠偏 2 次
DOOM_LOOP_WINDOW 6 死循环检测看最近 6 次工具调用
DOOM_LOOP_THRESHOLD 3 同工具同参数出现 3 次即判定疑似死循环
DOOM_LOOP_EXEMPT check_background_tasks 后台任务状态查询允许重复

Doom-loop(死循环)防护用于阻止同一工具同参数反复调用:

  • 精确匹配:最近 6 次调用中同一 (tool_name, args_hash) 出现 3 次。
  • 模糊匹配:参数文本长度达到 50 字符后,3-gram Jaccard 相似度达到 0.8 的同工具调用出现 3 次。
  • check_background_tasks 这类轮询工具豁免。

触发后 runtime(运行时)会写入工具错误 observation(观察结果),并中止本轮剩余工具调用,避免模型把外部 API(应用程序接口)或本地任务打爆。


7. 后台任务

后台任务在 cli/background.py,由 TUI(终端图形界面)初始化并注入 ToolRegistry(工具注册表)。

适合耗时较长但不应该阻塞对话的工具:

  • screen_stocks:全市场漏斗。
  • generate_ai_report:AI 深度研报。
  • generate_strategy_decision:攻防决策。
  • run_backtest:策略回测。

执行方式:

flowchart TD
    A["模型调用 background tool"] --> B["ToolRegistry.submit"]
    B --> C["BackgroundTaskManager 后台任务管理器创建 task 任务"]
    C --> D["daemon thread 守护线程执行真实函数"]
    B --> E["立即返回 task_id 任务编号给模型"]
    D --> F["progress callback 进度回调更新 TUI 终端图形界面面板"]
    D --> G["on_complete 完成回调注入通知"]
Loading

任务内部可通过 cli.progress 上报 stage(阶段)、detail(细节)、progress(进度)。TUI 顶部的 BackgroundTaskPanel(后台任务面板)展示活跃任务,用户可以继续提问。

结构化系统通知 ([SYSTEM NOTIFICATION])

后台任务运行完毕后,系统通过注入 [SYSTEM NOTIFICATION] 的方式唤醒并驱动 Agent Loop:

  • 通知格式:任务的执行报告会被格式化为类似 [SYSTEM NOTIFICATION - NOT USER INPUT]\n<task-notification>\n...\n</task-notification> 的结构注入到会话历史中。
  • 规避幻觉:这种前缀和 XML 标签可明确告知模型该内容并非来自用户的即时输入,防止模型产生角色幻觉(如误以为是用户在发问),从而让模型能够自然地在对话中总结后台任务的产出并主动汇报用户。

8. Sub-Agent 编排

// 考虑是否采用multi agent机制 探讨

Sub-agent(子智能体)基础设施在 cli/sub_agents.py,当前只有 CLI(命令行)/ TUI(终端图形界面)可用。

当前采用的是 Supervisor / tool-calling subagents 模式:主 Agent 保持会话控制权,把子 Agent 当作高阶工具调用;子 Agent 只负责局部任务,执行完成后把结构化结果交还给主 Agent。

flowchart TD
    U["用户"] <--> M["主 Agent / Supervisor"]

    M --> T["普通工具"]
    M --> R["delegate_to_research"]
    M --> A["delegate_to_analysis"]
    M --> D["delegate_to_trading"]

    R --> RR["research mini runtime"]
    RR --> M

    A --> AR["analysis mini runtime"]
    AR --> M

    D --> DR["trading mini runtime"]
    DR --> M
Loading

这不是 handoff(交接控制权):子 Agent 不接管后续对话。也不是 parallel workers(并发子 Agent 汇总):当前没有同时启动多个子 Agent 再统一投票/汇总的编排器。

三个内置角色的治理参数:

Sub-agent(子智能体) 职责 总超时 单工具超时 最大工具轮次 输入预算 输出预算
research 数据收集、全市场扫描、信号、复盘、回测 240s 90s 8 24K token 3000 chars
analysis 个股诊断、持仓体检、AI 研报 180s 75s 8 20K token 2500 chars
trading 去留决策、攻防指令、调仓计划 120s 45s 6 12K token 1600 chars

工具白名单:

  • researchsearch_stock_by_nameanalyze_stockget_market_overviewget_market_historyquery_historyscreen_stocksrun_backtestcheck_background_tasks
  • analysisanalyze_stockportfolioget_market_overviewget_market_historygenerate_ai_report
  • tradingportfoliogenerate_strategy_decisionanalyze_stockget_market_overviewget_market_history

主 Agent 通过 delegate_to_researchdelegate_to_analysisdelegate_to_trading 调用子 Agent。

实现要点:

  • SubAgentToolProxy(子智能体工具代理)只暴露允许的 schemas(工具定义),并在执行时拒绝越权工具。
  • SubAgentToolProxy 会为每次工具调用施加单工具超时;超时结果以工具错误返回给子 Agent,不继续阻塞主 Agent。
  • 每个 sub-agent(子智能体)启动自己的 AgentRuntime mini loop(小型循环),使用独立 system prompt(系统提示词)和上下文。
  • 子 Agent 使用同一个 provider(模型适配层)和 ToolRegistry(工具注册表),所以能共享登录态、数据源和确认机制。
  • 子 Agent 有独立 deadline(总耗时上限)和 max_tool_rounds(最大工具轮次),防止一个委派任务长期占用主 Agent。
  • 子 Agent 有 context_budget_tokens(输入上下文预算)和 result_budget_chars(输出结果预算):主 Agent 可以把较大的局部材料交给子 Agent 处理,但子 Agent 只向主 Agent 回传预算内的结论摘要。
  • 当传入 context 超过预算时,系统会确定性裁剪,只保留最近部分,并在上下文中加入“已按预算裁剪”的标记。这样子 Agent 能分担上下文压力,但不会变成无限上下文容器。
  • TUI 的取消信号会通过 tool_context.cancel_check 传给子 Agent;用户中断主任务时,子 Agent 也会返回 cancelled
  • TUI(终端图形界面)通过 tool_context.on_progress 转发子 Agent(子智能体)的 text_deltatool_starttool_resultdone 事件,以灰色斜体展示执行进度。

子 Agent 的返回结果是结构化对象,而不是只有一段自然语言:

{
    "agent": "research",
    "status": "completed | timeout | cancelled | error | empty",
    "result": "...",
    "usage": {"input_tokens": 0, "output_tokens": 0},
    "elapsed": 12.3,
    "rounds": 3,
    "tool_calls": ["get_market_overview", "analyze_stock"],
    "context_truncated": false,
    "result_truncated": false,
    "error": "",
    "policy": {
        "next_action": "use_result | fallback_to_direct_tools | stop_and_report_cancelled",
        "retryable": false,
        "fallback_tools": [],
        "instruction": "..."
    }
}

这里的 timeout 是子 Agent 自己的总 deadline、模型流式空闲超时或单工具超时;cancelled 是用户主动中断;error 是执行异常。主 Agent 可以基于 status 决定继续追问、降级回答,还是提示用户重试。

委派结果会附带 policy(结果策略),把失败后的处理收敛成结构化动作:

status next_action retryable fallback_tools
completed use_result false
timeout fallback_to_direct_tools true 对应角色的普通工具降级集
error fallback_to_direct_tools true 对应角色的普通工具降级集
empty fallback_to_direct_tools false 对应角色的普通工具降级集
cancelled stop_and_report_cancelled false

降级工具集按角色固定:research 使用 get_market_overviewquery_historycheck_background_tasksanalysis 使用 analyze_stockportfolioget_market_overviewtrading 使用 portfoliogenerate_strategy_decisionget_market_overview。主 Agent 收到失败结果后,不再自由猜测处理方式,而是按 policy 选择降级工具、停止任务或使用已有结论。

上下文分担的边界:

  • 主 Agent 保留用户最终目标、全局任务状态和高风险决策权。
  • 真实调仓执行只由主 Agent 在用户确认后调用 update_portfoliotrading 子 Agent 只输出交易计划和风险条件。
  • 子 Agent 处理局部大上下文,例如多标的诊断、行情材料归纳、工具返回对比和阶段性证据整理。
  • 子 Agent 返回的是压缩后的局部结论,不把完整工具过程和大段原始材料重新塞回主 Agent。
  • 如果 context_truncated=trueresult_truncated=true,主 Agent 应该把结论视为预算内摘要;需要精确字段时应重新调用工具或查看 scratchpad。

需要区分的是:子 Agent 不是后台 worker(后台工作进程)。delegate_to_* 本身是同步工具调用;如果子 Agent 内部调用 screen_stocksgenerate_ai_reportgenerate_strategy_decisionrun_backtest 这类 background=True 工具,这些长任务会被提交给 BackgroundTaskManager 并立即返回 task_id。子 Agent 不负责等待后台任务跑完,后续由主 Agent 通过 check_background_tasks 或系统通知继续衔接。

这个设计把“主 Agent 负责路由、规划、预算和最终回答”和“子 Agent 专注单类任务”拆开,减少复杂投研任务把主会话上下文搅乱。


9. Scratchpad(运行追踪文件)与可观测性

cli/scratchpad.py 为每个 CLI(命令行)/ TUI(终端图形界面)turn(对话轮次)写一份 JSONL trace(逐行 JSON 运行轨迹)到 ~/.wyckoff/scratchpad/

记录内容:

事件 字段
init 用户输入、session_id
thinking 模型 reasoning(推理内容)
tool_result 工具名、参数、结果、耗时、状态
compaction 压缩前后消息数
final 最终回复、token、耗时
error 异常信息

所有明显敏感字段会脱敏:api_keytokenpasswordsecretauthorizationcookie

Scratchpad(运行追踪文件)独立于 SQLite chat log(本地聊天日志):即使中途崩溃、工具超时或长任务异常,也能留下足够证据复盘“模型为什么这样回答”。


10. Web(网页端)Agent(智能体)

Web(网页端)核心文件:web/apps/web/src/lib/chat-agent.ts

它不是 CLI Runtime(命令行智能体运行时)的移植,而是面向 CF Pages(Cloudflare Pages 边缘页面)的独立实现:

  • 使用 streamText()(流式文本生成)做多步工具调用。
  • stopWhen(stepCountIs(10))(达到 10 步就停止)限制最大工具轮数。
  • 自己维护 StepInfo(步骤信息),向前端回调 onSteponTextDeltaonFinishonError
  • 使用 prepareChatMessagesForModel() 做本地摘要式上下文压缩。
  • 通过 /api/llm-proxy 代理模型请求,统一 base_url(模型服务地址)、安全校验和错误处理。
  • buildProxiedFetch() / wrapReasoningStream() 解析 SSE(服务端事件流)中的 reasoning_content(推理内容),并在下一轮补回 assistant message(助手消息),兼容 DeepSeek 等模型的 thinking mode(思考模式)。

Web(网页端)工具当前包括 13 个:

search_stockview_portfoliomarket_overviewmarket_historyquery_recommendationsquery_tail_buyplan_portfolio_updateexecute_portfolio_updateanalyze_stockscreen_stocksgenerate_ai_reportgenerate_strategy_decisionintraday_analysis

与 CLI(命令行)的差异:

  • Web(网页端)没有本地 shell/file/web_fetch(命令行、文件、网页抓取)工具。
  • Web(网页端)没有 CLI SQLite(命令行本地轻量数据库)记忆和 scratchpad(运行追踪文件)。
  • Web(网页端)调仓是 plan_portfolio_update + execute_portfolio_update 两步,靠 system prompt(系统提示词)和工具分离做确认边界。
  • Web(网页端)的 screen_stocks 是读取最新漏斗结果,不是在用户请求时启动 CLI(命令行)后台漏斗。

11. MCP Server(模型上下文协议服务)

MCP(模型上下文协议)入口:mcp_server.py

MCP Server(模型上下文协议服务)使用 FastMCP("wyckoff") 注册工具,外部 Agent(智能体)通过 stdio(标准输入输出)调用。

当前 MCP(模型上下文协议)工具有三类:

权限层 工具
Tier 1(第一权限层):本地历史 query_history
Tier 2(第二权限层):行情/引擎 search_stock_by_nameanalyze_stockget_market_overviewscreen_stocksrun_backtestmarket_regimewyckoff_diagnoseintraday_analysisintraday_rescue_checkrun_funnel_simulation
Tier 3(第三权限层):用户数据/LLM(大语言模型) portfolioupdate_portfoliogenerate_ai_reportgenerate_strategy_decision

MCP(模型上下文协议)会从环境变量或本地 CLI(命令行)登录态构造 ToolContext(工具上下文):

  • SUPABASE_USER_ID
  • SUPABASE_ACCESS_TOKEN
  • SUPABASE_REFRESH_TOKEN

与 CLI(命令行)/ Web(网页端)的关键区别是:MCP 自身不做 memory(记忆)、compaction(上下文压缩)、retry(纠偏重试)、sub-agent(子智能体)或后台面板。它只返回一次工具调用结果,复杂编排由 Claude Code、Cursor 等 MCP(模型上下文协议)客户端完成。

后续迭代计划:统一工具定义

当前工具定义分散在三处:MCP Server(mcp_server.py)、CLI 工具注册(cli/tools.py)和 Web Agent 工具(web/apps/api/src/routes/chat.ts)。后续目标不是把所有运行时都改成 MCP,而是抽出统一的 ToolDefinition(工具定义)作为内部唯一真源:

  1. 统一工具命名、描述、输入 schema(参数协议)、输出 schema(返回协议)和安全元数据。
  2. MCP Server 只作为一个 adapter(适配器),把统一工具定义暴露成 MCP tools。
  3. CLI provider(OpenAI / Claude / Gemini)从同一份定义生成各自需要的 function/tool schema。
  4. Web Agent 从同一份定义生成 AI SDK tool({ inputSchema })
  5. README / Wiki 的工具列表也从统一定义生成,避免文档、CLI、Web、MCP 四处漂移。

这样 MCP 是对外协议,ToolDefinition 是内部工具真源;多端共享同一套工具语义,但仍保留各自运行时的差异能力。


12. Skills(技能模板)与 Prompt(提示词)模板

Skills(技能模板)在 cli/skills.py,本质是“预设 user message(用户消息)模板”,执行后仍走完整 CLI Agent Runtime(命令行智能体运行时)。

技能延迟加载 (Skills Lazy Loading)

为了控制初始 System Prompt 的体积,避免模型因信息过载而分心,同时也为了最大化 Prompt Caching 的命中率,Wyckoff Agent 采用了**技能延迟加载(Lazy Loading)**的设计:

  1. Skills 列表动态注入:在每次 AgentRuntime.run_stream 启动时,系统只会把已加载的内置与用户自定义 Skills 的名称和简短描述<system-reminder> 块的形式注入到系统提示词底部。
  2. execute_skill 延迟展开:完整的 Skill 指令 Markdown 不会预置入 Context。当模型判定用户意图匹配某个 Skill(如 /checkup)时,它会主动调用 execute_skill 工具。此时,系统读取 Skill 对应文件的详细 instructions,并作为 tool_result 结果在下一轮返回给模型。这不仅节约了海量的前期输入 Token,还极大地加快了冷启动响应速度。

内置 Skills:

命令 作用
/screen 全市场漏斗筛选
/checkup 持仓健康体检
/report AI 深度研报
/strategy 攻守决策
/backtest 策略回测

用户可以在 ~/.wyckoff/skills/*.md 中新增 skill(技能模板)。支持 front matter(文件头元数据):

---
name: dcf
description: DCF 估值分析
---
请对 {user_input} 进行 DCF 估值分析。

Prompt(提示词)模板在 cli/prompt_templates.py,用于更结构化的投研任务;TUI(终端图形界面)/help 同时展示内置命令、prompt 模板和 skills(技能模板)。


13. 模型元数据与故障切换

当前 CLI(命令行)的模型选择由用户配置决定:默认模型负责正常对话,fallback(备用模型)只在可恢复错误时接管。

模型配置入口:

命令 作用
wyckoff model add 交互式添加 provider(模型供应商)、API key(接口密钥)、model(模型名)和 base_url(模型服务地址)
wyckoff model default <id> 设置默认模型
wyckoff model fallback <id> 设置 fallback(备用模型)
wyckoff model cost <id> --input-per-1m N --output-per-1m N --context-window N 配置成本和 context window(上下文窗口)元数据
wyckoff model usage --days N 汇总最近 N 天的本地模型用量和估算成本

模型元数据来源:

  1. 用户显式配置优先,例如 context_window、输入/输出 token(令牌)单价。
  2. 未配置 context_window 时,cli/model_metadata.py 按模型名推断常见上下文窗口。
  3. 未知模型按 64K token(令牌)上下文窗口处理,保证压缩阈值和 UI(用户界面)展示都有保守默认值。

FallbackProvider(故障切换模型适配层)的切换边界也比较明确:

  • 会切换:限流、超时、网络连接错误、服务端错误。
  • 不切换:API key(接口密钥)错误、模型名错误、参数不兼容等配置问题。
  • 切换后会更新当前 active provider(活跃模型适配层),runtime(运行时)读取到的 context_window 也跟随当前活跃模型。

这样的设计把“模型可用性恢复”收敛在 fallback(故障切换)里;不同任务使用哪个模型由用户在默认模型和备用模型配置中显式决定。


设计原则总结

原则 当前实现
Runtime(运行时)统一 CLI/TUI/sub-agent/test(命令行/终端图形界面/子智能体/测试)共享 AgentRuntime 事件循环
通道分层 Web(网页端)、CLI(命令行)、MCP(模型上下文协议)共享工具能力,但不强行共享同一运行时
数据先行 Loop Guard(循环守卫)把部分工具调用从 prompt(提示词)约束提升为状态机约束
工具可治理 schema、确认、并发安全、后台执行都在 ToolSpec / ToolRegistry 中声明
上下文可控 token budget(令牌预算)压缩、旧工具结果摘要、超大结果落盘
记忆克制 只沉淀稳定偏好和决策逻辑,不把临时行情写成长记忆
任务可拆 research(研究)/ analysis(分析)/ trading(交易)sub-agent 用工具代理隔离能力边界
运行可追溯 scratchpad(运行追踪)+ chat log(聊天日志)+ source_ref(来源引用)支撑问题复盘
Provider(模型适配层)无关 Claude / OpenAI-compatible(OpenAI 兼容接口)/ Gemini / fallback(故障切换)都收敛到同一接口
失败可恢复 超时、fallback(故障切换)、retry(纠偏重试)、doom-loop(死循环)中止和后台任务状态共同兜底

Clone this wiki locally