Просмотр исходного кода

update: support experience_path modification

guantao 1 неделя назад
Родитель
Сommit
78647ec8cd

+ 73 - 0
README.md

@@ -125,6 +125,79 @@ runner = AgentRunner(
 
 内置 skills(`agent/memory/skills/`)始终自动加载,`skills_dir` 的内容额外追加。
 
+## 经验系统(Experience System)
+
+经验系统通过**提取、注入、反馈、更新**四个环节,让 Agent 从历史执行中学习并持续改进。
+
+### 核心流程
+
+**1. 提取(Extract)**
+- **触发时机**:Level 2 压缩时自动触发
+- **提取方式**:在压缩历史消息前,先调用 LLM 对当前执行过程进行反思(reflect)
+- **输出格式**:ACE 规范经验条目
+  ```
+  当 [条件/Context] 时,应该 [动作/Action](原因:[逻辑/Reason])
+  ```
+- **存储位置**:追加到 `experiences.md` 文件(默认 `./.cache/experiences.md`)
+
+**2. 注入(Inject)**
+- **触发时机**:切换 Goal 时自动触发
+- **检索策略**:两阶段检索
+  - Stage 1: 语义路由(LLM 挑选 2*k 个相关经验)
+  - Stage 2: 质量精排(根据 metrics 筛选最终 k 个)
+- **注入方式**:将检索到的经验注入到主 Agent 的上下文中
+
+**3. 反馈(Feedback)**
+- **触发时机**:压缩时分析历史消息中经验的使用效果
+- **评价维度**:
+  - `helpful`: 经验有效,帮助完成任务
+  - `harmful`: 经验误导,导致错误
+  - `mixed`: 部分有效,需要改进
+- **反馈来源**:LLM 分析执行过程中经验的实际效果
+
+**4. 更新(Update)**
+- **Metrics 更新**:根据反馈调整 `helpful` 和 `harmful` 计数
+- **内容进化**:
+  - `helpful` + 有改进建议 → 触发经验重写(evolve)
+  - `harmful` 累积 → 降低检索权重或标记为有害
+- **质量过滤**:检索时自动过滤 `quality_score < -2` 的有害经验
+
+### 经验文件格式
+
+```markdown
+---
+id: ex_02271430_a3f2
+trace_id: 6822d4e0-8aeb-449f-962e-c431c409a5a0
+tags: {intent: [解构, 图片分析], state: [多图]}
+metrics: {helpful: 3, harmful: 0}
+created_at: 2026-02-27 14:30:15
+updated_at: 2026-02-27 15:20:42
+---
+当需要分析多张图片时,应该先并行读取所有图片再统一分析(原因:避免重复调用 LLM,节省 token 和时间)。
+```
+
+### 经验库瘦身
+
+`experience.py` 中提供 `slim_experiences()` 函数,可调用顶级 LLM 合并语义相似的经验,减少冗余。
+
+**功能**:
+- 识别并合并语义高度相似的经验
+- 保留 helpful 最高的 ID
+- 合并 metrics(helpful/harmful 取各条之和)
+- 保持 ACE 规范格式
+
+**状态**:已实现但暂未自动调用,可在 `analyze_story/run.py` 的交互菜单中手动触发(选项 7)。
+
+### 配置
+
+```python
+runner = AgentRunner(
+    llm_call=...,
+    trace_store=...,
+    experiences_path="./.cache/experiences.md",  # 自定义经验文件路径
+)
+```
+
 ## AgentRunner 参数
 
 ```python

+ 4 - 2
agent/core/runner.py

@@ -218,6 +218,7 @@ class AgentRunner:
         self.utility_llm_call = utility_llm_call
         self.config = config or AgentConfig()
         self.skills_dir = skills_dir
+        # 确保 experiences_path 不为 None
         self.experiences_path = experiences_path
         self.goal_tree = goal_tree
         self.debug = debug
@@ -772,7 +773,8 @@ class AgentRunner:
                     try:
                         relevant_exps = await _get_structured_experiences(
                             query_text=current_goal.description,
-                            top_k=3
+                            top_k=3,
+                            context={"runner": self}
                         )
                         if relevant_exps:
                             self.used_ex_ids = [exp['id'] for exp in relevant_exps]
@@ -1157,7 +1159,7 @@ created_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
                             elif result == "mixed":
                                 update_map[ex_id] = {"action": "helpful", "feedback": ""}
                     if update_map:
-                        count = await _batch_update_experiences(update_map)
+                        count = await _batch_update_experiences(update_map, context={"runner": self})
                         logger.info("经验评估完成,更新了 %s 条经验", count)
             except Exception as e:
                 logger.warning("经验评估解析失败(不影响压缩): %s", e)

+ 105 - 60
agent/tools/builtin/experience.py

@@ -6,12 +6,28 @@ import asyncio
 import re
 from typing import List, Optional, Dict, Any
 from datetime import datetime
-from ...llm.openrouter import openrouter_llm_call 
+from ...llm.openrouter import openrouter_llm_call
 
 logger = logging.getLogger(__name__)
 
-# 固定经验存储路径
-EXPERIENCES_PATH = "./.cache/experiences.md"
+# 默认经验存储路径(当无法从 context 获取时使用)
+DEFAULT_EXPERIENCES_PATH = "./.cache/experiences_0.md"
+
+def _get_experiences_path(context: Optional[Any] = None) -> str:
+    """
+    从 context 中获取 experiences_path,回退到默认路径。
+
+    context 可能包含 runner 引用,从中读取配置的路径。
+    """
+    if context and isinstance(context, dict):
+        runner = context.get("runner")
+        if runner and hasattr(runner, "experiences_path"):
+            path = runner.experiences_path or DEFAULT_EXPERIENCES_PATH
+            print(f"[Experience] 使用 runner 配置的路径: {runner.experiences_path}")
+            return path
+
+    print(f"[Experience] 使用默认路径: {DEFAULT_EXPERIENCES_PATH}")
+    return DEFAULT_EXPERIENCES_PATH
 
 # ===== 经验进化重写 =====
 async def _evolve_body_with_llm(old_body: str, feedback: str) -> str:
@@ -100,30 +116,44 @@ async def _route_experiences_by_llm(query_text: str, metadata_list: List[Dict],
         logger.error(f"LLM 经验路由失败: {e}")
         return []
 
-async def _get_structured_experiences(query_text: str, top_k: int = 3):
+async def _get_structured_experiences(query_text: str, top_k: int = 3, context: Optional[Any] = None):
     """
     1. 解析物理文件
     2. 语义路由:提取 2*k 个 ID
     3. 质量精排:基于 Metrics 筛选出最终的 k 个
     """
-    if not os.path.exists(EXPERIENCES_PATH):
-        print(f"[Experience System] 警告: 经验文件不存在 ({EXPERIENCES_PATH})")
+    print(f"[Experience System]  runner.experiences_path:  {context.get('runner').experiences_path if context and context.get('runner') else None}")
+    experiences_path = _get_experiences_path(context)
+
+    if not os.path.exists(experiences_path):
+        print(f"[Experience System] 警告: 经验文件不存在 ({experiences_path})")
         return []
 
-    with open(EXPERIENCES_PATH, "r", encoding="utf-8") as f:
+    with open(experiences_path, "r", encoding="utf-8") as f:
         file_content = f.read()
 
     # --- 阶段 1: 解析 ---
-    entries = file_content.split("---")
+    # 使用正则表达式匹配 YAML frontmatter 块,避免误分割
+    pattern = r'---\n(.*?)\n---\n(.*?)(?=\n---\n|\Z)'
+    matches = re.findall(pattern, file_content, re.DOTALL)
+
     content_map = {}
     metadata_list = []
 
-    for i in range(1, len(entries), 2):
+    for yaml_str, raw_body in matches:
         try:
-            metadata = yaml.safe_load(entries[i])
-            raw_body = entries[i+1].strip()
+            metadata = yaml.safe_load(yaml_str)
+
+            # 检查 metadata 类型
+            if not isinstance(metadata, dict):
+                logger.error(f"跳过损坏的经验块: metadata 不是 dict,而是 {type(metadata).__name__}")
+                continue
+
             eid = metadata.get("id")
-            
+            if not eid:
+                logger.error("跳过损坏的经验块: 缺少 id 字段")
+                continue
+
             meta_item = {
                 "id": eid,
                 "tags": metadata.get("tags", {}),
@@ -131,10 +161,11 @@ async def _get_structured_experiences(query_text: str, top_k: int = 3):
             }
             metadata_list.append(meta_item)
             content_map[eid] = {
-                "content": raw_body,
+                "content": raw_body.strip(),
                 "metrics": meta_item["metrics"]
             }
-        except Exception:
+        except Exception as e:
+            logger.error(f"跳过损坏的经验块: {e}")
             continue
 
     # --- 阶段 2: 语义路由 (取 2*k) ---
@@ -173,32 +204,39 @@ async def _get_structured_experiences(query_text: str, top_k: int = 3):
     print(f"[Experience System] 检索结束。\n")
     return result
 
-async def _batch_update_experiences(update_map: Dict[str, Dict[str, Any]]):
+async def _batch_update_experiences(update_map: Dict[str, Dict[str, Any]], context: Optional[Any] = None):
     """
     物理层:批量更新经验。
     修正点:正确使用 new_sections 集合,确保文件结构的完整性与并发进化的同步。
     """
-    if not os.path.exists(EXPERIENCES_PATH) or not update_map:
+    experiences_path = _get_experiences_path(context)
+
+    if not os.path.exists(experiences_path) or not update_map:
         return 0
 
-    with open(EXPERIENCES_PATH, "r", encoding="utf-8") as f:
+    with open(experiences_path, "r", encoding="utf-8") as f:
         full_content = f.read()
 
-    sections = full_content.split("---")
-    # new_sections 用于存放最终要写回的所有块
-    new_sections = [sections[0]] 
-    
+    # 使用正则表达式解析,避免误分割
+    pattern = r'---\n(.*?)\n---\n(.*?)(?=\n---\n|\Z)'
+    matches = re.findall(pattern, full_content, re.DOTALL)
+
+    new_entries = []
     evolution_tasks = []
-    # 记录哪些 new_sections 的索引需要填充进化后的 Body
-    # 注意:这里的 index 指的是 new_sections 里的位置
-    evolution_registry = {} 
+    evolution_registry = {}  # task_idx -> entry_idx
 
-    # --- 第一阶段:处理所有块,填充 new_sections ---
-    for i in range(1, len(sections), 2):
+    # --- 第一阶段:处理所有块 ---
+    for yaml_str, body in matches:
         try:
-            meta = yaml.safe_load(sections[i])
-            body = sections[i+1]
+            meta = yaml.safe_load(yaml_str)
+            if not isinstance(meta, dict):
+                logger.error(f"跳过损坏的经验块: metadata 不是 dict")
+                continue
+
             eid = meta.get("id")
+            if not eid:
+                logger.error("跳过损坏的经验块: 缺少 id")
+                continue
 
             if eid in update_map:
                 instr = update_map[eid]
@@ -218,19 +256,16 @@ async def _batch_update_experiences(update_map: Dict[str, Dict[str, Any]]):
                     # 注册进化任务
                     task = _evolve_body_with_llm(body.strip(), feedback)
                     evolution_tasks.append(task)
-                    # 记录该任务对应 new_sections 列表中的位置
-                    # 此时 new_sections 已经存了 [0], 接下来要存 [meta, body]
-                    # 所以 meta 在 len(new_sections), body 在 len(new_sections) + 1
-                    evolution_registry[len(evolution_tasks) - 1] = len(new_sections) + 1
+                    # 记录该任务对应的 entry 索引
+                    evolution_registry[len(evolution_tasks) - 1] = len(new_entries)
                     meta["metrics"]["helpful"] += 1
 
                 meta["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
 
-            # 无论是否更新,都将其序列化并加入 new_sections
-            meta_str = "\n" + yaml.dump(meta, allow_unicode=True).strip() + "\n"
-            new_sections.append(meta_str)
-            new_sections.append(body) # 先放旧 Body,进化后再替换
-            
+            # 序列化并加入 new_entries
+            meta_str = yaml.dump(meta, allow_unicode=True).strip()
+            new_entries.append((meta_str, body.strip()))
+
         except Exception as e:
             logger.error(f"跳过损坏的经验块: {e}")
             continue
@@ -239,40 +274,49 @@ async def _batch_update_experiences(update_map: Dict[str, Dict[str, Any]]):
     if evolution_tasks:
         print(f"🧬 并发处理 {len(evolution_tasks)} 条经验进化...")
         evolved_results = await asyncio.gather(*evolution_tasks)
-        
-        # 精准回填
-        for task_idx, section_idx in evolution_registry.items():
-            new_sections[section_idx] = f"\n{evolved_results[task_idx].strip()}\n"
+
+        # 精准回填:替换对应 entry 的 body
+        for task_idx, entry_idx in evolution_registry.items():
+            meta_str, _ = new_entries[entry_idx]
+            new_entries[entry_idx] = (meta_str, evolved_results[task_idx].strip())
 
     # --- 第三阶段:原子化写回 ---
-    # 使用 new_sections 构建最终文本
-    final_content = "---".join(new_sections)
-    with open(EXPERIENCES_PATH, "w", encoding="utf-8") as f:
+    final_parts = []
+    for meta_str, body in new_entries:
+        final_parts.append(f"---\n{meta_str}\n---\n{body}\n")
+
+    final_content = "\n".join(final_parts)
+    with open(experiences_path, "w", encoding="utf-8") as f:
         f.write(final_content)
-    
+
     return len(update_map)
 
 # ===== 经验库瘦身 =====
 
-async def slim_experiences(model: str = "anthropic/claude-sonnet-4.5") -> str:
+async def slim_experiences(model: str = "anthropic/claude-sonnet-4.5", context: Optional[Any] = None) -> str:
     """
     经验库瘦身:调用顶级大模型,将经验库中语义相似的经验合并精简。
     返回瘦身报告字符串。
     """
-    if not os.path.exists(EXPERIENCES_PATH):
+    experiences_path = _get_experiences_path(context)
+
+    if not os.path.exists(experiences_path):
         return "经验文件不存在,无需瘦身。"
 
-    with open(EXPERIENCES_PATH, "r", encoding="utf-8") as f:
+    with open(experiences_path, "r", encoding="utf-8") as f:
         file_content = f.read()
 
-    # 解析所有经验条目
-    entries = file_content.split("---")
+    # 使用正则表达式解析,避免误分割
+    pattern = r'---\n(.*?)\n---\n(.*?)(?=\n---\n|\Z)'
+    matches = re.findall(pattern, file_content, re.DOTALL)
+
     parsed = []
-    for i in range(1, len(entries), 2):
+    for yaml_str, body in matches:
         try:
-            meta = yaml.safe_load(entries[i])
-            body = entries[i + 1].strip()
-            parsed.append({"meta": meta, "body": body})
+            meta = yaml.safe_load(yaml_str)
+            if not isinstance(meta, dict):
+                continue
+            parsed.append({"meta": meta, "body": body.strip()})
         except Exception:
             continue
 
@@ -372,7 +416,7 @@ REPORT: 原有 X 条,合并后 Y 条,精简了 Z 条。
 
         # 写回文件
         final = "\n".join(new_entries)
-        with open(EXPERIENCES_PATH, "w", encoding="utf-8") as f:
+        with open(experiences_path, "w", encoding="utf-8") as f:
             f.write(final)
 
         result = f"瘦身完成:{len(parsed)} → {len(new_entries)} 条经验。"
@@ -387,17 +431,18 @@ REPORT: 原有 X 条,合并后 Y 条,精简了 Z 条。
 
 # ===== 对外 Tool 接口 =====
 
-from agent.tools import tool
+from agent.tools import tool, ToolContext
 
 @tool(description="通过两阶段检索获取最相关的历史经验")
-async def get_experience(query: str, k: int = 3):
+async def get_experience(query: str, k: int = 3, context: Optional[ToolContext] = None):
     """
     通过两阶段检索获取最相关的历史经验。
     第一阶段语义匹配(2*k),第二阶段质量精排(k)。
     """
     relevant_items = await _get_structured_experiences(
         query_text=query,
-        top_k=k
+        top_k=k,
+        context=context
     )
 
     if not relevant_items:
@@ -409,7 +454,7 @@ async def get_experience(query: str, k: int = 3):
     }
 
 @tool()
-async def update_experiences(feedback_list: List[Dict[str, Any]]):
+async def update_experiences(feedback_list: List[Dict[str, Any]], context: Optional[ToolContext] = None):
     """
     批量反馈历史经验的有效性。
     
@@ -438,5 +483,5 @@ async def update_experiences(feedback_list: List[Dict[str, Any]]):
             "feedback": comment
         }
 
-    count = await _batch_update_experiences(update_map)
+    count = await _batch_update_experiences(update_map, context)
     return f"成功同步了 {count} 条经验的反馈。感谢你的评价!"

+ 30 - 0
examples/how/input/1_invariant_features.json

@@ -0,0 +1,30 @@
+{
+  "构图精确描述": {
+    "人物在画面中的位置比例": "人物位于画面右侧,占据画面垂直方向约2/3的高度,水平方向约1/3的宽度,身体大部分在画面内,但头部和左臂部分被裁切,形成一种开放式构图。",
+    "画架的位置": "画架位于画面左侧偏中,占据画面垂直方向约1/2的高度,水平方向约1/4的宽度,其底部支撑腿延伸至画面左下角。",
+    "画中画的位置关系": "“画中画”位于画架上,正对着人物,其左侧边缘与画架左侧边缘大致对齐,右侧边缘靠近人物的右臂,占据画面中心偏左的位置。"
+  },
+  "色彩分布": {
+    "主色调": "绿色(草地和背景树木),白色(人物服装和画中画中的人物)。",
+    "辅助色": "蓝色和紫色(画中画中的花朵),棕色(人物头发、画架),以及调色板上丰富的颜料色彩。",
+    "色温感受": "整体色温偏冷,绿色背景营造出清新、自然的氛围,白色服装和画中画的冷色调花朵进一步加强了这种感受。"
+  },
+  "光线方向和质感": {
+    "光线方向": "光线从画面左上方或人物的左侧照射过来,在人物的右侧和画架的右侧形成轻微的阴影,画中画的左侧也略亮于右侧。",
+    "质感": "光线柔和,没有明显的硬光或强烈阴影,呈现出一种自然光照的效果,可能是在户外阴天或傍晚时分拍摄。"
+  },
+  "人物姿态": {
+    "手持画笔的姿势": "右手握持画笔,笔尖朝向画中画,正在进行绘画动作,姿势专注而优雅。",
+    "身体朝向": "身体略微向左前方倾斜,背对镜头,头部微微转向画中画,呈现出侧身专注绘画的姿态。",
+    "重心": "重心似乎落在双腿上,身体姿态稳定,没有明显的倾斜或不平衡感。"
+  },
+  "画面中的\"画中画\"内容和风格": {
+    "内容": "画中画描绘了一个穿着白色裙子的女性背影,置身于一片蓝色和紫色调的花海或草地中,背景有绿色植物。",
+    "风格": "画风偏向印象派或后印象派,笔触粗犷,色彩鲜明,注重光影和色彩的表达,而非细节的精确描绘,具有一定的艺术感和浪漫气息。"
+  },
+  "景深处理": {
+    "哪些在焦内": "人物(尤其是头发、手臂和服装)、画架、画中画以及调色板清晰可见,处于景深范围内。",
+    "哪些虚化": "背景的绿色树木和草地被明显虚化,呈现出柔和的模糊效果,突出了前景的人物和绘画主题。"
+  },
+  "整体画面的情绪氛围是如何通过制作手段营造的": "整体画面营造出一种宁静、专注、艺术和自然的氛围。通过以下制作手段实现:\n1. **构图**:开放式构图和人物侧身背对镜头,增加了画面的故事感和神秘感,引导观众关注人物的创作过程。\n2. **色彩**:主色调的绿色和白色带来清新、纯洁的感受,画中画的冷色调花朵增添了艺术气息,整体色彩和谐统一。\n3. **光线**:柔和的自然光线使得画面温馨舒适,没有强烈的视觉冲击,营造出平和的氛围。\n4. **景深**:浅景深处理有效地将观众的注意力集中在人物和她的创作上,虚化的背景则提供了自然的户外环境,增强了画面的艺术感和沉浸感。\n5. **人物姿态**:人物专注绘画的姿态,展现了对艺术的热爱和投入,传递出一种平静而富有创造力的情绪。\n6. **画中画**:画中画的印象派风格和内容,暗示了人物的艺术追求和作品的浪漫主题,进一步烘托了艺术创作的氛围。"
+}

+ 23 - 0
examples/how/input/3_invariant_features.json

@@ -0,0 +1,23 @@
+{
+  "构图精确描述": {
+    "人物在画面中的位置": "人物位于画面右下角,占据了画面约四分之一的区域,背对镜头,面向画架。",
+    "画架的位置": "画架位于画面中心偏右,与人物形成一个斜向的视觉引导线,画板上的画作是构图的视觉焦点之一。",
+    "背景元素分布": "背景由大片绿色草地和茂密的树木组成。草地占据了画面下方约三分之一的区域,树木和天空占据了上方约三分之二的区域。左侧和右侧的树木形成了一个自然的框架,中间部分透出明亮的天空和远处的建筑轮廓(模糊可见)。"
+  },
+  "逆光/侧逆光的具体效果": {
+    "光晕": "画面左上角和背景树木的边缘有明显的光晕效果,光线穿透树叶,形成柔和的金色光斑,营造出温暖、梦幻的氛围。",
+    "轮廓光": "人物的头发和右侧手臂(如果可见)边缘有轻微的轮廓光,使其从背景中分离出来,增加立体感。画架的木质结构也因逆光而呈现出清晰的轮廓。",
+    "草地光感": "草地整体受光均匀,但由于逆光,草地的颜色显得更加饱和,部分区域呈现出被阳光照亮的金绿色,增强了画面的层次感和生动性。"
+  },
+  "人物姿态": {
+    "跪坐的具体姿势": "人物呈跪坐姿势,双膝着地,身体微微前倾,双手似乎正在操作画板或调色板。身体重心稳定,姿态放松。",
+    "身体朝向": "人物身体完全背对镜头,头部略微向右侧转动,但仍主要面向画架,专注于绘画。"
+  },
+  "色彩分布": {
+    "绿色草地": "绿色草地占据了画面下方大部分区域,颜色鲜亮,饱和度较高,是画面中最主要的色彩之一。",
+    "白色人物": "人物身着白色长裙,在绿色的背景中显得尤为突出,形成鲜明的对比,白色也反射了周围环境的光线,显得纯洁而明亮。",
+    "金色光晕": "画面左上角和背景树木边缘的金色光晕,为画面增添了温暖和柔和的色调,与绿色和白色共同构成了画面的主色调,比例上金色光晕虽然面积不大,但视觉冲击力强。"
+  },
+  "景深处理": "画面采用了浅景深处理。前景的草地和人物、画架清晰锐利,是画面的焦点。背景的树木和远处的建筑则被虚化,呈现出柔和的模糊效果,有效地突出了主体,并营造出一种空间感和层次感。",
+  "整体画面的情绪氛围是如何通过制作手段营造的": "整体画面营造出一种宁静、专注、艺术且充满诗意的氛围。这主要通过以下制作手段实现:\n1. **构图**:人物背对镜头,将观众的视线引向画架和画作,强调了绘画这一行为的专注性。\n2. **光线**:逆光/侧逆光的使用,特别是柔和的金色光晕,为画面增添了温暖、梦幻和浪漫的色彩,营造出傍晚或清晨的静谧感。\n3. **色彩**:绿色草地、白色长裙和金色光晕的搭配,色彩清新、和谐,给人以自然、纯洁、舒适的视觉感受。\n4. **景深**:浅景深将焦点集中在人物和画作上,虚化的背景避免了杂乱,使画面更加纯粹,增强了艺术感和沉浸感。\n5. **人物姿态**:跪坐的姿态显得谦逊而投入,进一步强化了专注和沉思的情绪。"
+}

+ 25 - 0
examples/how/input/7_invariant_features.json

@@ -0,0 +1,25 @@
+{
+  "构图精确描述": {
+    "人物脸部位置": "人物脸部位于画面右侧偏上,占据了画面右侧约三分之一的区域,呈侧面轮廓,视线朝向画面左侧。",
+    "玫瑰花位置": "玫瑰花位于画面左侧偏下,花朵部分与人物鼻尖和嘴唇大致处于同一水平线,花茎和叶子延伸至画面左下角,与人物手部相接。"
+  },
+  "景深处理": {
+    "焦点": "焦点清晰地落在人物的脸部(特别是侧脸轮廓、眼睛和嘴唇)以及她手中的白色玫瑰花上。",
+    "背景虚化程度": "背景虚化程度非常高,呈现出柔和的模糊效果,将背景的绿色草地处理成大面积的色块,有效地突出了前景的人物和玫瑰花。"
+  },
+  "光线方向和质感": {
+    "侧光具体效果": "光线从人物的右前方(或略偏上方)射入,形成明显的侧光效果。人物的鼻梁、额头、脸颊和下巴边缘被光线勾勒出清晰的轮廓,呈现出明亮的线条。头发的边缘也因侧光而显得有光泽。玫瑰花的右侧花瓣也受到光照,显得明亮。整体光线质感柔和,没有出现硬朗的阴影,表明可能使用了柔光设备或在柔和的自然光下拍摄。"
+  },
+  "人物面部特征的制作呈现": {
+    "皮肤质感": "皮肤质感细腻,呈现出自然的光泽感,没有明显的瑕疵或毛孔,整体显得光滑饱满。脸颊和鼻尖处有轻微的高光,增加了立体感。",
+    "妆容风格": "妆容风格自然清淡,强调裸妆感。眉毛自然,眼妆部分不明显,可能只做了简单的睫毛处理。唇部涂有水润的红色唇彩,为整体清淡的妆容增添了一抹亮色和活力。"
+  },
+  "玫瑰花的视觉处理": {
+    "清晰度": "玫瑰花的花朵部分清晰可见,花瓣的层次和纹理细节都得到了很好的呈现。",
+    "色彩": "玫瑰花呈现纯净的白色,与人物的白色上衣相呼应,与背景的绿色形成对比,色彩饱和度适中,显得清新雅致。"
+  },
+  "整体色调和情绪氛围": {
+    "整体色调": "整体色调偏向清新、明亮和柔和。以人物的肤色、白色衣物和玫瑰花为亮色调,背景的绿色草地为中性偏冷的绿色调,唇部的红色为点缀色。色彩搭配和谐,视觉上舒适。",
+    "情绪氛围": "画面营造出一种宁静、温柔、浪漫和愉悦的情绪氛围。人物闭眼轻嗅玫瑰花的动作,传达出一种沉浸于美好瞬间的享受和满足感。侧光和柔和的景深处理进一步增强了这种梦幻和唯美的感觉。"
+  }
+}

+ 54 - 0
examples/how/input/set_invariant_features.json

@@ -0,0 +1,54 @@
+{
+  "整体视觉风格和色调": "这组图片呈现出清新、自然、艺术的风格。色调以绿色(草地、树木)和白色(人物服装、玫瑰花)为主,辅以画板上的丰富色彩,整体明亮柔和,营造出一种宁静而富有生机的氛围。",
+  "每张图的主要元素": {
+    "图1": {
+      "人物": "一位长发女性,身穿白色长裙,侧身面向画架,手持画笔和调色板,正在作画。",
+      "场景": "户外草地,背景有绿树。",
+      "道具": "木质画架、画布(上面有未完成的画作,画中人物与现实人物相似)、调色板、画笔、一朵白色玫瑰花(插在画架旁)。",
+      "文字": "无"
+    },
+    "图2": {
+      "人物": "同一位长发女性,身穿白色长裙,背对镜头,跪坐在草地上,面向画架。",
+      "场景": "户外草地,背景有绿树和远处模糊的建筑轮廓。",
+      "道具": "木质画架、画布(上面有未完成的画作)、一个白色桶状容器(可能装有画材)。",
+      "文字": "无"
+    },
+    "图3": {
+      "人物": "同一位长发女性的侧脸特写,闭着眼睛,面带微笑,正在闻一朵白色玫瑰花。",
+      "场景": "户外草地(背景模糊)。",
+      "道具": "一朵白色玫瑰花、耳环、项链。",
+      "文字": "无"
+    }
+  },
+  "构图方式和元素关系": {
+    "图1": "采用斜向构图,人物和画架形成一个对角线,引导视线从左下到右上。人物与画作形成“画中画”的趣味关系,现实中的人物正在描绘画中的自己,增加了艺术性和故事感。白色玫瑰花作为点缀,与人物的白色裙子相呼应。",
+    "图2": "采用中心构图,人物和画架位于画面中央,背景的树木形成自然的框架。人物背对镜头,强调了其专注于创作的状态,也留给观众想象空间。人物跪坐的姿态显得谦逊而投入。",
+    "图3": "采用特写构图,聚焦于人物的侧脸和手中的玫瑰花。人物的脸部占据画面大部分,背景虚化,突出主体。玫瑰花靠近人物的鼻子,强调了“闻花”的动作和感受,营造出一种宁静美好的氛围。"
+  },
+  "光线处理": {
+    "图1": "光线明亮柔和,似乎是自然光,没有明显的阴影,整体曝光均匀。光线从侧面或前方照射,使人物和画作细节清晰。",
+    "图2": "光线呈现出逆光或侧逆光的效果,背景的树木边缘有金色的光晕,草地也显得更加明亮。这种光线处理营造出温暖、梦幻的氛围,尤其是在日落时分或清晨的光线效果。",
+    "图3": "光线柔和,从侧面照射,使人物的脸部轮廓和玫瑰花的细节清晰可见。没有强烈的对比,整体光线均匀,突出了人物的柔美和宁静。"
+  },
+  "多图之间的固定要素和变化要素": {
+    "固定要素": [
+      "同一位长发女性(身穿白色长裙)",
+      "户外草地场景",
+      "木质画架和画布(画作内容相似)",
+      "白色玫瑰花(在不同图中出现或作为道具)",
+      "清新自然的整体风格"
+    ],
+    "变化要素": [
+      "人物的姿态和动作(作画、跪坐、闻花)",
+      "拍摄角度和景别(侧身全身、背影全身、侧脸特写)",
+      "光线效果(均匀光、逆光/侧逆光)",
+      "画面焦点和强调的主题(作画过程、专注状态、享受自然)"
+    ]
+  },
+  "特殊的视觉处理手法": [
+    "\"画中画\"效果:图1中人物正在描绘画中的自己,这种艺术手法增加了画面的层次感和趣味性。",
+    "景深运用:所有图片都使用了浅景深,虚化背景,突出主体人物和道具,使画面更具电影感和艺术感。",
+    "色彩搭配:白色服装与绿色草地形成鲜明对比,同时白色玫瑰花与服装相呼应,整体色彩和谐统一。",
+    "叙事性:三张图片通过不同角度和动作,共同讲述了一个关于艺术创作、享受自然和自我沉浸的小故事,具有一定的连贯性和叙事性。"
+  ]
+}

+ 36 - 4
examples/how/run.py

@@ -120,12 +120,13 @@ async def show_interactive_menu(
     print("  1. 插入干预消息并继续")
     print("  2. 触发经验总结(reflect)")
     print("  3. 查看当前 GoalTree")
-    print("  4. 继续执行")
-    print("  5. 停止执行")
+    print("  4. 手动压缩上下文(compact)")
+    print("  5. 继续执行")
+    print("  6. 停止执行")
     print("=" * 60)
 
     while True:
-        choice = input("请输入选项 (1-5): ").strip()
+        choice = input("请输入选项 (1-6): ").strip()
 
         if choice == "1":
             text = _read_multiline()
@@ -199,10 +200,40 @@ async def show_interactive_menu(
             continue
 
         elif choice == "4":
+            # 手动压缩上下文
+            print("\n正在执行上下文压缩(compact)...")
+            try:
+                goal_tree = await store.get_goal_tree(trace_id)
+                trace = await store.get_trace(trace_id)
+                if not trace:
+                    print("未找到 Trace,无法压缩")
+                    continue
+
+                # 重建当前 history
+                main_path = await store.get_main_path_messages(trace_id, trace.head_sequence)
+                history = [msg.to_llm_dict() for msg in main_path]
+                head_seq = main_path[-1].sequence if main_path else 0
+                next_seq = head_seq + 1
+
+                compact_config = RunConfig(trace_id=trace_id)
+                new_history, new_head, new_seq = await runner._compress_history(
+                    trace_id=trace_id,
+                    history=history,
+                    goal_tree=goal_tree,
+                    config=compact_config,
+                    sequence=next_seq,
+                    head_seq=head_seq,
+                )
+                print(f"\n✅ 压缩完成: {len(history)} 条消息 → {len(new_history)} 条")
+            except Exception as e:
+                print(f"\n❌ 压缩失败: {e}")
+            continue
+
+        elif choice == "5":
             print("\n继续执行...")
             return {"action": "continue"}
 
-        elif choice == "5":
+        elif choice == "6":
             print("\n停止执行...")
             return {"action": "stop"}
 
@@ -268,6 +299,7 @@ async def main():
         trace_store=store,
         llm_call=create_openrouter_llm_call(model=f"anthropic/claude-{prompt.config.get('model', 'sonnet-4.5')}"),
         skills_dir=skills_dir,
+        experiences_path="./.cache/experiences_how.md",
         debug=True
     )