v0.16.0: Agent ReAct Loop 深度优化 R51-R128 (上下文管理/安全治理/性能分析/会话持久化)
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-v0.15.0-E8734A?style=flat-square" alt="version">
|
||||
<img src="https://img.shields.io/badge/version-v0.16.0-E8734A?style=flat-square" alt="version">
|
||||
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
|
||||
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
||||
@@ -253,7 +253,7 @@ npm start
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||
```
|
||||
|
||||
产出:`release/Metona Ollama Setup v0.15.0.exe`
|
||||
产出:`release/Metona Ollama Setup v0.16.0.exe`
|
||||
|
||||
## 🛠️ 常用命令
|
||||
|
||||
@@ -501,7 +501,7 @@ npm start
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||
```
|
||||
|
||||
Output: `release/Metona Ollama Setup v0.15.0.exe`
|
||||
Output: `release/Metona Ollama Setup v0.16.0.exe`
|
||||
|
||||
## 🛠️ Common Commands
|
||||
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
# 🔄 Agentic Loop(智能体循环)完全指南
|
||||
|
||||
> **一句话定义**:Agentic Loop 是驱动 AI Agent 持续自主完成复杂任务的核心调度循环——让 AI 不断重复"思考 → 行动 → 观察 → 再思考"的闭环过程,直到任务完成。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [一、核心概念](#一核心概念)
|
||||
- [二、与传统 LLM 的本质区别](#二与传统-llm的本质区别)
|
||||
- [三、运作机制:五阶闭环详解](#三运作机制五阶闭环详解)
|
||||
- [四、三层分级体系](#四三层分级体系)
|
||||
- [五、循环终止条件](#五循环终止条件)
|
||||
- [六、核心伪代码实现](#六核心伪代码实现)
|
||||
- [七、与主流 Agent 范式的关系](#七与主流-agent-范式的关系)
|
||||
- [八、工具调用(Tool Use)六步闭环](#八工具调用tool-use六步闭环)
|
||||
- [九、适用场景与不适用场景](#九适用场景与不适用场景)
|
||||
- [十、实际应用案例](#十实际应用案例)
|
||||
- [十一、关联循环体系](#十一关联循环体系)
|
||||
- [十二、行业趋势与未来方向](#十二行业趋势与未来方向)
|
||||
- [十三、参考来源](#十三参考来源)
|
||||
|
||||
---
|
||||
|
||||
## 一、核心概念
|
||||
|
||||
### 什么是 Agentic Loop?
|
||||
|
||||
**Agentic Loop(智能体循环)**是 AI Agent 架构中的核心运行机制,它让大语言模型(LLM)从传统的"一问一答"模式升级为能够持续自主地完成复杂任务的闭环系统。
|
||||
|
||||
如果把普通 AI 比作「随时待命的客服」,只负责被动应答;那带 Loop 的 AI Agent,就是 **不用催促、自主推进的全职实习生**,懂规划、会试错、能复盘、可收尾。
|
||||
|
||||
### 为什么需要它?
|
||||
|
||||
传统的 LLM 交互是线性的"一问一答"——用户提问,模型一次性生成回答,交互即结束。一旦任务复杂、变数较多,就会直接"摆烂",需要人不断细化指令、手动推进进度。
|
||||
|
||||
而 **Agentic Loop 引入了「循环」和「反馈」两个关键要素**,使 AI 能够根据中间结果动态调整策略,边执行边验证,而不是死板地执行预设流程。
|
||||
|
||||
> 💡 **没有 Agent Loop,AI 只是聊天工具;有了 Agent Loop,AI 才是能落地干活的智能体。**
|
||||
|
||||
---
|
||||
|
||||
## 二、与传统 LLM 的本质区别
|
||||
|
||||
| 维度 | 传统 LLM | Agentic Loop |
|
||||
|------|----------|--------------|
|
||||
| **交互方式** | 线性"一问一答" | 循环式"思考→行动→观察" |
|
||||
| **任务能力** | 单次内容生成 | 持续自主完成复杂任务 |
|
||||
| **反馈机制** | 无中间反馈 | 基于环境反馈动态调整 |
|
||||
| **适应性** | 静态输出 | 动态纠错与策略优化 |
|
||||
| **记忆能力** | 无状态 | 可选配持久化记忆 |
|
||||
| **工具使用** | 不支持 | 原生支持多工具调用 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Traditional["传统 LLM:单次交互"]
|
||||
A1[用户提问] --> B1[LLM 一次性生成]
|
||||
B1 --> C1[输出结果 · 结束]
|
||||
end
|
||||
|
||||
subgraph Agentic["Agentic Loop:闭环迭代"]
|
||||
A2[设定目标] --> B2[思考决策]
|
||||
B2 --> C2[行动执行]
|
||||
C2 --> D2[观察反馈]
|
||||
D2 --> E2{任务完成?}
|
||||
E2 -->|否| B2
|
||||
E2 -->|是| F2[输出最终结果]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、运作机制:五阶闭环详解
|
||||
|
||||
Agentic Loop 的完整工作流包含五个阶段,在多数任务中可简化为持续迭代的三步核心循环。
|
||||
|
||||
### 完整五阶段
|
||||
|
||||
#### ① 目标感知(Perceive)
|
||||
人类只需给出 **最终目标和核心规则**,不用拆解步骤、不用预设细节。例如:
|
||||
- "整理一份2026年新媒体行业报告"
|
||||
- "排查这段代码的运行漏洞并修复"
|
||||
- "规划一周减脂食谱+运动计划"
|
||||
|
||||
这一步彻底告别了过去「精准逐字写提示词」的繁琐。
|
||||
|
||||
#### ② 思考决策(Reason)
|
||||
Agent 结合自身知识库、记忆体系和工具能力,把大目标拆成可落地的小步骤,同时判断:
|
||||
- 下一步该做什么?
|
||||
- 需要调用什么工具?
|
||||
- 优先执行哪项任务?
|
||||
|
||||
> **核心区别:步骤不是人定的,是 AI 自己推理出来的。**
|
||||
|
||||
#### ③ 行动执行(Act)
|
||||
根据决策结果执行具体动作:
|
||||
- 🔍 联网搜索信息
|
||||
- 💻 调用代码工具 / 执行 Shell 命令
|
||||
- 📄 读取本地文件
|
||||
- ✍️ 生成文案内容
|
||||
- 📊 批量处理数据
|
||||
|
||||
这是 AI 从「只会输出文字」到「可以真实做事」的关键一步。
|
||||
|
||||
#### ④ 观察反馈(Observe)
|
||||
Agent 会主动感知执行后的真实状态:
|
||||
- 搜索的信息是否全面?
|
||||
- 代码是否运行成功?
|
||||
- 生成的内容是否缺漏?
|
||||
- 数据是否存在异常?
|
||||
|
||||
它不再盲目输出,而是 **能看见自己的执行结果**。
|
||||
|
||||
#### ⑤ 复盘修正(Reflect/Correct)
|
||||
这是 Loop 最核心的灵魂。Agent 会自我校验:
|
||||
- 当前结果是否达标?
|
||||
- 有没有遗漏步骤?
|
||||
- 有没有错误漏洞?
|
||||
- 是否需要调整方法?
|
||||
|
||||
如果不满足终止条件,立刻修正策略、重启下一轮循环;如果达标,才终止任务、输出最终结果。
|
||||
|
||||
### 核心三步简化版
|
||||
|
||||
在实际工程中,五阶段常被精简为最核心的三步循环:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────┐│
|
||||
│ │ 推理 │──▶│ 行动 │──▶│ 观察 ││
|
||||
│ │ Reasoning│ │ Acting │ │Observ. ││
|
||||
│ └──────────┘ └──────────┘ └────────┘│
|
||||
│ ▲ │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ 未完成则继续循环 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> **简单总结:思考 → 行动 → 观察 → 评估 → 修正,往复循环,闭环落地。**
|
||||
|
||||
---
|
||||
|
||||
## 四、三层分级体系
|
||||
|
||||
Agentic Loop 不是固定模板,随着记忆存储、工具管理、配套功能完善,可分为三个层级。开发中遇到的绝大多数问题——AI 重复执行相同操作、遗忘前文对话、多轮回答前后逻辑矛盾——根源基本都是 **任务复杂度与智能体层级不匹配**。
|
||||
|
||||
### 第一层:基础工具调用循环
|
||||
|
||||
**最简形态**,仅依靠大模型调用工具并输出回答,没有持久化记忆、没有外部状态存储。
|
||||
|
||||
```
|
||||
用户输入 → LLM 推理 → 调用工具 → 观察结果 → 回传 LLM → 输出最终答案
|
||||
```
|
||||
|
||||
**特点:**
|
||||
- ✅ 处理独立、简短的一次性任务完全够用
|
||||
- ❌ 无法留存历史对话,每次启动都是全新空白状态
|
||||
- ❌ 上下文窗口是唯一临时存储载体,流程结束后所有状态清空
|
||||
- ❌ 用于多轮对话时会出现:重复检索运算、遗忘前文决策、前后自相矛盾
|
||||
|
||||
**适合场景**:单次问答、简单查询、一次性工具调用
|
||||
|
||||
---
|
||||
|
||||
### 第二层:内置完整生命周期的循环
|
||||
|
||||
升级后,循环内部新增标准化 **记忆操作流程**:
|
||||
- 调用大模型前 **读取** 历史记忆数据
|
||||
- 智能体完成动作后 **写入/更新** 记忆
|
||||
- 整套循环形成完整闭环生命周期
|
||||
|
||||
#### 关键区分:记忆增强型 vs 记忆感知型
|
||||
|
||||
| 类型 | 说明 | 能力上限 |
|
||||
|------|------|----------|
|
||||
| **记忆增强型** | 仅被动检索信息注入上下文,不会主动管控内存 | 较低 —— 记忆是外部附加能力 |
|
||||
| **记忆感知型** | 将内存作为核心工程模块,主动完成编码、存储、检索、注入、遗忘全套操作 | 较高 —— 在单次/跨会话中持续维护自身推理状态 |
|
||||
|
||||
第二层是搭建 **记忆感知型智能体** 的起点。
|
||||
|
||||
#### 第二层常见挑战及缓解方案
|
||||
|
||||
| 挑战 | 说明 | 缓解方法 |
|
||||
|------|------|----------|
|
||||
| **检索噪声** | 语义相似但与当前查询实际不相关 | 设置相关性阈值 + 混合检索 + 多级过滤 |
|
||||
| **陈旧记忆** | 快速变化领域中数据很快过时 | 设置 TTL(生存时间)策略 + 写时更新模式 |
|
||||
| **工具定义过载** | 工具太多导致上下文膨胀,降低选择准确性 | 采用语义工具检索而非穷举所有工具 |
|
||||
|
||||
**适合场景**:多轮对话、长周期任务、需要上下文延续的复杂工作流
|
||||
|
||||
---
|
||||
|
||||
### 第三层:Harness 工程体系级循环
|
||||
|
||||
工程师不仅能管控循环内部逻辑,还在循环外围搭建一套 **设计规范、功能完善的 Harness 框架**。系统操作分为两大板块:
|
||||
|
||||
| 板块 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **循环内操作** | 程序自动执行 | 自动加载上下文、执行工具调用 |
|
||||
| **循环外操作** | 智能体自主触发 | AI 判断是否需要额外信息、主动请求人工确认 |
|
||||
|
||||
#### 第三层必需的三类优化手段
|
||||
|
||||
1. **上下文窗口监控**:实时统计每轮 Token 占用,提前预判溢出风险,及时触发压缩
|
||||
2. **对话压缩**:用精简摘要替代冗长聊天记录,原始消息永久保存在数据库,支持审计和按需展开
|
||||
3. **工具输出离线存储**:完整工具返回结果存入独立日志表,上下文仅保留一行引用标识
|
||||
|
||||
> **第三层的核心升级不在于内层的基础循环逻辑,而是循环外围一整套配套支撑系统:数据加载框架、运行约束管控、跨会话持久化存储层。此时整套 Harness 本身已经是一套独立、成熟、可单独运维的工程系统。**
|
||||
|
||||
**适合场景**:生产级应用、企业级部署、高可靠性要求的复杂 Agent 系统
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
L1["第一层: 基础工具调用循环\n✅ 单次任务 / 简单查询"] --> L2["第二层: 内置记忆的完整生命周期\n✅ 多轮对话 / 长周期任务"]
|
||||
L2 --> L3["第三层: Harness 工程体系\n✅ 生产级 / 企业级部署"]
|
||||
|
||||
style L1 fill:#e8f5e9,stroke:#4caf50
|
||||
style L2 fill:#fff3e0,stroke:#ff9800
|
||||
style L3 fill:#e3f2fd,stroke:#2196f3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、循环终止条件
|
||||
|
||||
任何循环都必须设置退出机制。设计完善的 Agentic Loop 会明确定义全部终止规则:
|
||||
|
||||
### 主流终止条件
|
||||
|
||||
| # | 终止条件 | 说明 |
|
||||
|---|----------|------|
|
||||
| 1 | **模型输出最终回复** | 无待执行的工具调用(`tool_calls` 为空) |
|
||||
| 2 | **系统校验任务完成** | Harness 主动校验目标已达成 |
|
||||
| 3 | **达到最大迭代次数** | 默认通常设为 10~20 次,防止无限循环 |
|
||||
| 4 | **运行时长超限** | 全局超时保护,双重管控资源消耗 |
|
||||
| 5 | **不可恢复的系统错误** | 发生无法自动修复的异常 |
|
||||
| 6 | **死循环检测** | 连续多轮重复执行相同操作,无任何进展 |
|
||||
| 7 | **Agent 主动结束** | 智能体发出完成标记 |
|
||||
|
||||
### ⚠️ 常见误区
|
||||
|
||||
> **模型不再发起工具调用 ≠ 用户需求已全部完成**
|
||||
|
||||
模型可能输出追问、部分结果或需要补充交互的内容。任务是否真正闭环,需要 **Harness 主动校验**,不能单纯依靠模型停止调用工具来判断。任务流程越长、逻辑越复杂,二者的差距越明显。
|
||||
|
||||
### 卡死故障检测
|
||||
|
||||
成熟的 Harness 框架会缓存近期全部工具调用记录,识别以下停滞模式后直接终止流程并输出诊断日志:
|
||||
|
||||
- 连续三轮用完全相同参数调用同一个工具
|
||||
- AI 在两种状态间反复来回切换、毫无进展
|
||||
|
||||
---
|
||||
|
||||
## 六、核心伪代码实现
|
||||
|
||||
以下是所有 AI 智能体、自动化工作流的底层通用代码逻辑(无编程基础也能看懂):
|
||||
|
||||
```python
|
||||
# ============================================
|
||||
# Agentic Loop 核心闭环逻辑(通用极简版)
|
||||
# ============================================
|
||||
|
||||
def agent_loop(目标任务):
|
||||
# ---- 1. 初始化 ----
|
||||
当前状态 = "未完成"
|
||||
最大循环次数 = 20 # 防止无限死循环
|
||||
历史执行记录 = []
|
||||
|
||||
# ---- 2. 主循环 ----
|
||||
while 当前状态 == "未完成" and 循环次数 < 最大循环次数:
|
||||
|
||||
# ① 思考决策:根据目标 + 历史反馈 规划下一步动作
|
||||
下一步动作 = llm_思考推理(目标任务, 历史执行记录)
|
||||
|
||||
# ② 行动执行:调用工具、落地操作
|
||||
执行结果 = 工具执行(下一步动作)
|
||||
|
||||
# ③ 观察反馈:记录本次执行的所有数据和状态
|
||||
历史执行记录.append({
|
||||
"动作": 下一步动作,
|
||||
"结果": 执行结果,
|
||||
"时间戳": 当前时间()
|
||||
})
|
||||
|
||||
# ④ 复盘评估:判断是否达标、是否需要优化
|
||||
任务是否完成 = llm_校验(目标任务, 执行结果)
|
||||
|
||||
if 任务是否完成 == True:
|
||||
当前状态 = "已完成"
|
||||
else:
|
||||
# 未达标,自动修正策略,开启下一轮循环
|
||||
修正策略 = llm_纠错优化(目标任务, 历史执行记录)
|
||||
# (修正策略会自然融入下一轮 llm_思考推理)
|
||||
|
||||
# ---- 3. 输出最终成果 ----
|
||||
return 最终整合结果(历史执行记录)
|
||||
```
|
||||
|
||||
### 代码核心解读
|
||||
|
||||
| 要点 | 说明 |
|
||||
|------|------|
|
||||
| **`while` 循环是灵魂** | 普通 AI 没有 `while` 循环,只会执行一次输出;Agent Loop 依靠持续循环实现反复干活、反复优化 |
|
||||
| **自带记忆迭代** | 每一轮都记录「动作 + 结果」,下一轮参考历史数据,越循环越精准 |
|
||||
| **自带终止机制** | 最大循环次数 + 任务校验双保险,既保证自主迭代又避免无效死循环 |
|
||||
|
||||
### 更底层的 LangChain 风格实现
|
||||
|
||||
```python
|
||||
# ============================================
|
||||
# 用 LangChain 实现的核心循环(更接近真实工程)
|
||||
# ============================================
|
||||
|
||||
while not done:
|
||||
# 1. 构建上下文(system prompt + 历史消息 + 当前输入)
|
||||
messages = build_context(system_prompt, history, current_input)
|
||||
|
||||
# 2. 调用 LLM API(流式或非流式)
|
||||
response = call_llm(messages)
|
||||
|
||||
# 3. 解析响应
|
||||
if response.has_tool_calls:
|
||||
# 4a. 有工具调用 → 逐个执行
|
||||
results = execute_tools(response.tool_calls)
|
||||
# 5a. 结果追加到历史
|
||||
history.append(response)
|
||||
history.append(results)
|
||||
# → 回到步骤 1,进入下一次迭代
|
||||
else:
|
||||
# 4b. 无工具调用 → 输出最终回复
|
||||
done = True
|
||||
return response.content
|
||||
```
|
||||
|
||||
> 一个简单的"帮我查天气"请求通常需要 **2~3 次**迭代;而复杂任务如"帮我改这个 bug"可能需要 **10+ 次**迭代——读文件、运行测试、编辑代码、再运行测试……
|
||||
|
||||
---
|
||||
|
||||
## 七、与主流 Agent 范式的关系
|
||||
|
||||
Agentic Loop 是多个 AI Agent 工程范式的共同基础设施:
|
||||
|
||||
### 🔄 ReAct 模式(最基础、最具代表性)
|
||||
|
||||
由 Shunyu Yao 等人于 **2022 年**在论文《ReAct: Synergizing Reasoning and Acting in Language Models》中提出。
|
||||
|
||||
- **核心思想**:将 **思维链推理(CoT)** 与 **外部环境交互行动** 相结合
|
||||
- **解决的问题**:弥补单纯 LLM 缺乏实时信息和容易产生幻觉的缺陷
|
||||
- **影响范围**:已成为现代 AI 代理设计的基准,深刻影响了 **LangChain** 和 **LlamaIndex** 等后续框架
|
||||
|
||||
**ReAct 的经典执行轨迹示例**(排查线上服务变慢):
|
||||
|
||||
```
|
||||
Thought 1: 用户报告服务变慢,我需要先查看监控指标
|
||||
Action 1: query_monitoring(service="api-server")
|
||||
Observation 1: CPU 使用率 95%,响应时间 P99 > 5s
|
||||
|
||||
Thought 2: CPU 飙升且响应慢,可能是慢 SQL 导致的,检查数据库
|
||||
Action 2: query_database_logs(time_range="last_hour")
|
||||
Observation 2: 发现全表扫描查询 SELECT * FROM orders WHERE ...
|
||||
|
||||
Thought 3: 已定位根因——缺少索引导致的全表扫描,通知负责人修复
|
||||
Action 3: send_alert(channel="slack", message="发现慢SQL根因...")
|
||||
Observation 3: ✅ 告警已发送
|
||||
|
||||
Final Answer: 服务变慢的原因是 orders 表缺少索引导致全表扫描,
|
||||
已发送告警给 DBA 团队,建议立即添加索引。
|
||||
```
|
||||
|
||||
### 📋 Plan-and-Execute(先规划再执行)
|
||||
|
||||
在 Loop 中引入更复杂的任务分解和调度机制:
|
||||
1. 先制定完整的分步计划(Plan)
|
||||
2. 再逐步执行每个子任务(Execute)
|
||||
3. 执行过程中可根据结果回溯调整计划
|
||||
|
||||
### 🔍 Reflection(反思机制)
|
||||
|
||||
在 Loop 中增加 **自我评估和纠错**环节:
|
||||
- 每步行动后评估效果
|
||||
- 从失败中学习
|
||||
- 提升长期表现
|
||||
|
||||
### 👤 Human-in-the-Loop(人机协同 / HITL)
|
||||
|
||||
将人类作为核心参与者整合到 Agentic 生命周期中(而非仅仅作为监督者):
|
||||
- 满足企业的法律、合规和安全要求
|
||||
- AI 在关键节点主动暂停,列出待确认项等待人工审核
|
||||
- **不是兜底 bug,而是架构设计的分层逻辑**
|
||||
|
||||
### 🤖 Multi-Agent System(多智能体协作)
|
||||
|
||||
多个 Agent 各自拥有独立的 Agentic Loop,通过协作模式共同完成任务:
|
||||
- **并行模式**:多个 Agent 同时处理不同子任务
|
||||
- **顺序模式**:按流水线顺序传递处理
|
||||
- **循环模式**:Agent 之间形成协作循环
|
||||
|
||||
---
|
||||
|
||||
## 八、工具调用(Tool Use)六步闭环
|
||||
|
||||
大模型本质上是"静态文本生成器",训练后无法自动更新知识,也不能执行计算、查询私有数据或操作外部系统。要让模型"感知并行动",必须把外部能力包装成 **"工具"**,让模型按需调用。
|
||||
|
||||
### 六步闭环流程
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ① 工具定义 → ② LLM 决策 → ③ 结构化调用 │
|
||||
│ ↑ │ │
|
||||
│ └── ⑥ 最终回答 ← ⑤ 结果回传 ← ④ 执行层运行 ←───┘│
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 步骤 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| **① 工具定义** | 用 JSON Schema 或框架装饰器描述函数名、功能、字段类型与语义 | 供 LLM 读取和理解 |
|
||||
| **② LLM 决策** | 将"用户提问 + 可用工具描述"一起输入模型 | 模型内部判断是否需要调用工具 |
|
||||
| **③ 结构化调用** | 模型输出 JSON 格式的调用指令 | 如 `{"name": "get_weather", "arguments": {"city": "London"}}` |
|
||||
| **④ 执行层截获运行** | 框架根据 JSON 路由到真实函数 | 完成 API 请求、数据库查询、代码运行等 |
|
||||
| **⑤ 结果回传** | 函数返回值作为新上下文再喂给 LLM | 让模型基于真实结果继续推理 |
|
||||
| **⑥ 最终回答** | 模型结合原始提问与工具观察生成答案 | 若仍缺信息可循环 ②~⑤ |
|
||||
|
||||
### LangChain 代码示例
|
||||
|
||||
```python
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.tools import tool
|
||||
from langchain.agents import create_agent
|
||||
|
||||
# ① 定义工具
|
||||
@tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""查询指定城市的天气"""
|
||||
return f"{city}今天晴,25°C"
|
||||
|
||||
# ② 创建带工具的 Agent(内部编译出 StateGraph 循环)
|
||||
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
|
||||
agent = create_agent(llm, [get_weather])
|
||||
|
||||
# ③ 运行(自动进入 Agentic Loop)
|
||||
result = agent.invoke({
|
||||
"messages": [{"role": "user", "content": "北京今天天气怎么样?"}]
|
||||
})
|
||||
print(result["messages"][-1].content)
|
||||
# → 今天北京天气晴,气温25°C
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、适用场景与不适用场景
|
||||
|
||||
### ✅ 适合使用 Agentic Loop 的场景
|
||||
|
||||
| 场景特征 | 典型例子 |
|
||||
|----------|----------|
|
||||
| **步骤数无法事先确定** | "调研某个技术领域并输出完整报告" |
|
||||
| **需要根据中间结果动态调整策略** | 搜索失败时换关键词重试;代码报错时换方案修复 |
|
||||
| **任务完成度比速度更重要** | 深度资料分析、复杂代码重构 |
|
||||
| **多步骤、长链路任务** | 自动办公流程、多轮内容创作 |
|
||||
| **需要自我纠错的能力** | 排查 Bug、调试程序 |
|
||||
|
||||
### ❌ 不适合使用 Agentic Loop 的场景
|
||||
|
||||
| 场景特征 | 原因 | 更好的选择 |
|
||||
|----------|------|------------|
|
||||
| **固定序列的工作流** | 高度可预测、步骤固定 | 确定性的代码 Pipeline |
|
||||
| **简单任务** | 一次 LLM + 一次工具调用就能解决 | 直接函数调用 |
|
||||
| **严格的延迟约束** | 每次迭代都要调 LLM,时间和 Token 成本累积 | 预计算 / 缓存 |
|
||||
|
||||
---
|
||||
|
||||
## 十、实际应用案例
|
||||
|
||||
### 🖥️ 开发场景:Claude Code / Cursor / OpenClaw
|
||||
|
||||
每一次与编程助手的交互会话,本质上都是一个 Agentic Loop:
|
||||
|
||||
```
|
||||
读取用户请求 → 检查代码仓库 → 编辑文件 → 运行测试 → 识别报错 → 再次编辑 → ... → 构建成功
|
||||
```
|
||||
|
||||
这套 **推理 → 行动 → 观察结果** 的往复流程,如今几乎所有的生产级编程智能体都以它为核心。
|
||||
|
||||
### 📊 办公场景
|
||||
|
||||
- 自动整理会议纪要 → 提炼重点 → 拆解待办 → 生成执行方案 → 跟进任务进度
|
||||
- 读取表格数据 → 分析异常 → 生成可视化报告 → 给出优化建议
|
||||
|
||||
### ✍️ 内容场景
|
||||
|
||||
- 自主选题 → 搜索资料 → 梳理框架 → 撰写初稿 → 校对纠错 → 优化措辞 → 排版输出
|
||||
|
||||
### 🏠 智能硬件场景
|
||||
|
||||
扫地机器人避障规划、智能温控调节、自动驾驶路况判断——本质都是持续 **感知 → 决策 → 行动 → 修正** 的小型 Agent Loop 循环。
|
||||
|
||||
> **所有「越用越聪明、能自主干活」的 AI,核心都是 Agentic Loop。**
|
||||
|
||||
---
|
||||
|
||||
## 十一、关联循环体系
|
||||
|
||||
Agentic Loop 并非孤立存在,外部多层循环会直接影响其架构设计:
|
||||
|
||||
### 三大关联循环
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
TL["🔄 训练循环<br/>数据采集 → 梯度更新 → 效果评估 → 版本发布<br/>离线流程 · 周期以天/周计"]
|
||||
|
||||
AL["🔄 Agentic Loop(智能体循环)<br/>推理 → 行动 → 观察 → 再推理<br/>在线实时流程 · 以秒计"]
|
||||
|
||||
FL["🔄 反馈循环<br/>工具返回 → 用户修正 → 量化指标 → 评估监控"]
|
||||
|
||||
HL["🔄 人工介入循环<br/>AI 暂停 → 人工审核 → 确认/修改 → 继续运行"]
|
||||
|
||||
AL -->|"产生交互数据"| FL
|
||||
FL -->|"存入记忆库"| TL
|
||||
AL -->|"触及权限边界"| HL
|
||||
HL -->|"确认后继续"| AL
|
||||
```
|
||||
|
||||
| 循环类型 | 性质 | 周期 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| **训练循环** | 离线 | 天/周 | 大模型诞生的底层流程;现阶段与 Agentic Loop 完全解耦(模型权重固定) |
|
||||
| **反馈循环** | 在线 | 实时 | 每次动作产生的反馈信号(工具返回、用户修正、量化指标);持续迭代进化的核心 |
|
||||
| **人工介入循环** | 按需 | 不定 | AI 遇到无法自主决策的节点时主动暂停,等待人工确认 |
|
||||
|
||||
### ⚠️ 关键边界:训练循环 vs Agentic Loop
|
||||
|
||||
现阶段两类循环 **完全解耦**:
|
||||
- 模型训练完成后权重固定,Agent 在静态权重之上运行
|
||||
- 对话中表现出的"记忆""学习""纠错",**并非更新模型权重**,只是从内存检索历史信息
|
||||
- 分清两者边界才能精准定位问题:需要优化记忆存储?还是重新训练大模型?
|
||||
|
||||
---
|
||||
|
||||
## 十二、行业趋势与未来方向
|
||||
|
||||
### AI 技术的三次范式跃迁
|
||||
|
||||
```
|
||||
判别式 AI ──▶ 生成式 AI ──▶ Agentic AI
|
||||
(分类/预测) (ChatGPT) (自主执行任务)
|
||||
```
|
||||
|
||||
英伟达 CEO 黄仁勋在 **2025 GTC 大会**上宣称:我们即将步入 **Agentic AI 时代**。英伟达称 Agentic AI 为 **"人工智能的下一个前沿"**。
|
||||
|
||||
### 从 Prompt Engineering 到 Loop Engineering
|
||||
|
||||
> **AI 的竞争,从「单次提示优化」变成了「循环系统设计」。**
|
||||
|
||||
| 过去(Prompt Engineering) | 现在(Loop Engineering) |
|
||||
|---------------------------|--------------------------|
|
||||
| 反复打磨提示词 | 设计更合理的 Agent Loop |
|
||||
| 细化指令、预设场景 | 优化推理逻辑、调整循环策略 |
|
||||
| 人工弥补 AI 不智能 | 让 AI 自己试错、自己优化 |
|
||||
| 单次交互质量 | 循环系统效能 |
|
||||
|
||||
### 未来方向:打通全链路持续学习
|
||||
|
||||
当前智能体循环、模型训练循环、反馈循环分属三套独立开发体系。未来方向是将它们 **打通闭环**:
|
||||
|
||||
```
|
||||
Agentic Loop 产出真实交互经验
|
||||
↓
|
||||
存入记忆库(高质量数据)
|
||||
↓
|
||||
持续学习技术(Continual Learning)
|
||||
↓
|
||||
把经验融入模型参数
|
||||
↓
|
||||
模型越来越强 → Agent 越来越聪明
|
||||
```
|
||||
|
||||
届时 **记忆存储的数据质量将直接决定训练素材质量**——规整清晰的聊天记录、精准提取的关键信息、可靠的反馈评价,能产出高质量训练数据;杂乱无章、无规划存储的对话,无法用于模型迭代。
|
||||
|
||||
---
|
||||
|
||||
## 十三、核心价值总结
|
||||
|
||||
Agentic Loop 赋予了 AI 系统四大核心能力:
|
||||
|
||||
| 能力 | 图标 | 说明 |
|
||||
|------|------|------|
|
||||
| **自主性 (Autonomy)** | 🧠 | 无需持续人工干预即可持续推进任务 |
|
||||
| **目标导向 (Goal-Directed)** | 🎯 | 理解抽象目标并将其转化为具体行动序列 |
|
||||
| **动态适应 (Adaptive)** | 🔄 | 通过持续的环境反馈优化策略 |
|
||||
| **多模态协作 (Multi-tool)** | 🔗 | 整合多源数据,支持跨工具/跨系统调用 |
|
||||
|
||||
### 一句话总结
|
||||
|
||||
> **没有 Agentic Loop,AI Agent 就只是一个静态的问答工具;有了它,AI 才真正具备了"持续自主完成复杂任务"的能力。**
|
||||
>
|
||||
> **单次输出是工具,循环闭环才是智能。**
|
||||
|
||||
---
|
||||
|
||||
## 参考来源
|
||||
|
||||
### 核心论文
|
||||
- Yao, S., et al. (2022). **ReAct: Synergizing Reasoning and Acting in Language Models** — [CSDN 解析](https://blog.csdn.net/enjoyedu/article/details/159416232)
|
||||
|
||||
### 深度解析文章
|
||||
- [解读 Agent Loop(智能体循环)的三层分级体系](https://www.111cn.net/new/602954.htm) — 一聚教程网
|
||||
- [看懂 Agent Loop:AI 从「被动问答」到「自主干活」的核心密码](https://cloud.tencent.com/developer/article/2694843) — 腾讯云开发者社区
|
||||
- [推理 → 行动 → 观察:用 LangChain + Python 实现一个智能体循环](https://blog.csdn.net/m0_46510245/article/details/161346563)
|
||||
- [OpenAI 解析 Codex CLI 核心机制:Agent Loop 工作流程详解](https://www.imooc.com/article/388435)
|
||||
- [OpenClaw 源码深度解析:Agent Loop 如何调用 LLM 和工具](https://blog.csdn.net/ha_9527/article/details/158903245)
|
||||
- [深入解析 Agent 内部机制:六大核心支柱](https://blog.csdn.net/l01011_/article/details/161116948)
|
||||
- [Agent 底层运行逻辑拆解:读懂 Agent Loop 与 Turn 运行本质](https://blog.csdn.net/u013970991/article/details/161750174)
|
||||
|
||||
### 设计模式与架构
|
||||
- [《Agentic Design Patterns》中文电子书](https://blog.csdn.net/weixin_54416957/article/details/159979689) — 涵盖反思、工具使用、规划、多智能体协作等模式
|
||||
- [AI 智能体的系统架构与核心设计模式](https://blog.csdn.net/Code1994/article/details/151221580)
|
||||
- [Agentic Design Patterns 第5章:Tool Use 工具调用模式](https://blog.csdn.net/wangyaninglm/article/details/153145056)
|
||||
- [LangChain 源码解析:Function Call 是如何被执行的](https://cloud.tencent.com/developer/article/2657653)
|
||||
- [Orchestrator 为什么比 Agentic Loop 快:LLM 决策与执行分离](https://so.html5.qq.com/page/real/search_news?docid=70000021_0236a280df558552)
|
||||
|
||||
### 行业趋势
|
||||
- [2026 版 Agentic AI 从原理到实战完整指南](https://blog.csdn.net/weixin_59191169/article/details/160925182)
|
||||
- [CES 2025:Agentic AI 将如何改变未来的工作和生活](https://www.sohu.com/a/844233934_121902920)
|
||||
- [2025 GTC 大会:Agentic AI 与 Robotic AI 的未来探讨](https://www.sohu.com/a/875875299_121902920)
|
||||
- [IBM: What Is Agentic Reasoning?](https://www.ibm.com/think/topics/agentic-reasoning)
|
||||
- [Human-in-the-Loop 人机协同策略](https://blog.csdn.net/Anspire/article/details/150609597)
|
||||
- [一文读懂 Agentic AI 技术点滴](http://www.51testing.com/mobile/view.php?itemid=7804918)
|
||||
|
||||
---
|
||||
|
||||
> 📝 **文档版本**:v2.0(增强版)
|
||||
> 📅 **最后更新**:2026-07-11
|
||||
> 📖 **本文档基于公开技术资料整理,涵盖概念解析、分层体系、代码实现、工程实践与行业趋势
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "metona-ollama-desktop",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
||||
"main": "dist/main/main.js",
|
||||
"author": "thzxx",
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export function createMenu(): void {
|
||||
dialog.showMessageBox(mainWindow!, {
|
||||
type: 'info',
|
||||
title: '关于 Metona Ollama',
|
||||
message: 'Metona Ollama Desktop v0.15.0',
|
||||
message: 'Metona Ollama Desktop v0.16.0',
|
||||
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
||||
icon: getIconPath()
|
||||
});
|
||||
|
||||
@@ -378,15 +378,40 @@ let _streamRenderPending = false;
|
||||
let _streamRenderContent = '';
|
||||
let _streamRenderThink: string | null = null;
|
||||
let _streamRenderModel: string | undefined;
|
||||
// R58: 流式渲染优化 — 增量阈值 + 内容指纹,避免高频小片段冗余重渲染
|
||||
let _streamLastRenderedLen = 0; // 上次渲染时的内容长度
|
||||
let _streamLastRenderedHash = 0; // 上次渲染时的内容哈希(简单 DJB2)
|
||||
const STREAM_MIN_DELTA = 15; // 最小增量字符数(不足则跳过本次渲染)
|
||||
|
||||
/** R58: 快速 DJB2 哈希 */
|
||||
function _djb2(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0x7fffffff;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行实际的流式 Markdown 渲染
|
||||
* R58: 添加增量阈值检查 — 内容增长不足 STREAM_MIN_DELTA 字符时跳过渲染
|
||||
*/
|
||||
function _doStreamRender(): void {
|
||||
_streamRenderPending = false;
|
||||
const lastMsg = currentPlaceholder;
|
||||
if (!lastMsg) return;
|
||||
|
||||
// R58: 增量阈值检查 — 内容变化不足时跳过本次渲染
|
||||
const contentLen = _streamRenderContent.length;
|
||||
const contentHash = _djb2(_streamRenderContent);
|
||||
if (contentLen > 0 && contentHash === _streamLastRenderedHash) return; // 内容完全相同
|
||||
if (contentLen - _streamLastRenderedLen < STREAM_MIN_DELTA && contentLen > 100) {
|
||||
// 内容增长不足且已有内容 → 跳过本次,等下次 rAF
|
||||
return;
|
||||
}
|
||||
_streamLastRenderedLen = contentLen;
|
||||
_streamLastRenderedHash = contentHash;
|
||||
|
||||
const contentDiv = lastMsg.querySelector('.msg-content');
|
||||
if (contentDiv && _streamRenderContent) {
|
||||
// 智能补全不完整的 Markdown 语法后渲染
|
||||
@@ -460,6 +485,10 @@ export function updateLastAssistantMessage(
|
||||
requestAnimationFrame(_doStreamRender);
|
||||
}
|
||||
}
|
||||
} else if (isFinal) {
|
||||
// R58: 最终渲染时重置增量追踪状态
|
||||
_streamLastRenderedLen = 0;
|
||||
_streamLastRenderedHash = 0;
|
||||
}
|
||||
|
||||
let thinkBlock = lastMsg.querySelector('.think-block');
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="header-left">
|
||||
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
||||
<span class="app-title">Metona Ollama</span>
|
||||
<span class="app-version">v0.15.0</span>
|
||||
<span class="app-version">v0.16.0</span>
|
||||
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
|
||||
@@ -9,19 +9,65 @@ import { state, KEYS } from '../state/state.js';
|
||||
import {
|
||||
executeTool,
|
||||
getEnabledToolDefinitions,
|
||||
getRelevantToolDefinitions,
|
||||
needsConfirmation,
|
||||
initPlanTracker,
|
||||
getPlanTracker,
|
||||
formatPlanStatus,
|
||||
clearPlanTracker,
|
||||
} from './tool-registry.js';
|
||||
import {
|
||||
compactOldToolResult,
|
||||
detectOscillation,
|
||||
recordToolCallHistory,
|
||||
detectConsecutiveIdentical,
|
||||
setUserGoal,
|
||||
checkGoalAlignment,
|
||||
resetAllSafetyState,
|
||||
checkRateLimit,
|
||||
classifyError,
|
||||
calculateBackoff,
|
||||
validatePathSandbox,
|
||||
isToolCircuitBroken,
|
||||
recordToolFailure,
|
||||
recordToolSuccess,
|
||||
// R88: 工具结果元数据
|
||||
addResultMetadata,
|
||||
// R97: 错误模式学习
|
||||
recordErrorPattern,
|
||||
// R104: 工具结果去重
|
||||
isDuplicateToolResult,
|
||||
// R109: 工具参数消毒
|
||||
sanitizeToolArgs,
|
||||
// R112: 诊断系统
|
||||
collectDiagnostics,
|
||||
// R113: 命令安全检查
|
||||
checkCommandSafety,
|
||||
// R95: 按工具类型智能截断
|
||||
smartTruncateByToolType,
|
||||
// R99: 工具结果引用解析
|
||||
checkArchivedReferences,
|
||||
} from './agent-safety.js';
|
||||
import { search, formatMemoryContext, loadAllEntries } from './memory-service.js';
|
||||
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js';
|
||||
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
||||
import { generateId } from '../utils/utils.js';
|
||||
import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js';
|
||||
import {
|
||||
buildContext, estimateTokens, shouldAutoCompress, compressWithLLM,
|
||||
AUTO_COMPRESS_THRESHOLD, recordActualTokens, predictContextOverflow, recordTokenUsage,
|
||||
// R91: 上下文压力分级评估
|
||||
getContextPressureLevel,
|
||||
// R93: Token 预算追踪器
|
||||
recordBudgetUsage, setTokenBudgetNumCtx, resetTokenBudget,
|
||||
// R96: 消息角色压缩
|
||||
mergeConsecutiveMessages,
|
||||
// R98: 压缩触发阈值优化
|
||||
getTrendAwareCompressThreshold,
|
||||
// R100: Token 使用统计报告
|
||||
generateTokenReport, formatTokenReport,
|
||||
} from './context-manager.js';
|
||||
import { runCompletionGate } from './completion-gate.js';
|
||||
import { executeHooks } from './hooks.js';
|
||||
import { recordIteration, recordToolCall, recordCompletionGate, startSessionMetrics, endSessionMetrics } from './agent-metrics.js';
|
||||
@@ -43,6 +89,12 @@ const MAX_MESSAGES = 300; // 上下文硬上限,超过则强制压
|
||||
const API_MAX_RETRIES = 2; // Ollama API 调用失败重试次数
|
||||
const MAX_PARALLEL_TOOLS = 5; // R6: 单批次最大并行工具数
|
||||
|
||||
// R51-R54: 工具结果离线存储、震荡检测、死循环检测已迁移至 agent-safety.ts
|
||||
// R56: 目标对齐验证也已迁移至 agent-safety.ts
|
||||
|
||||
// R55: 语义工具检索 — 缓存当前轮过滤后的工具定义,避免 handleThinking 重复计算
|
||||
let _filteredTools: import('../types.js').ToolDefinition[] = [];
|
||||
|
||||
/**
|
||||
* 计算增量压缩触发阈值(按 token 数而非消息条数)
|
||||
* 当上下文 token 占比超过 numCtx 的 30% 时触发压缩
|
||||
@@ -145,45 +197,91 @@ const SIDE_EFFECT_TOOLS = new Set([
|
||||
* 防止单个工具调用挂起导致整个 Agent Loop 卡死
|
||||
* 0 = 不限制(仅依赖全局 AbortController)
|
||||
*/
|
||||
const TOOL_TIMEOUT_MAP: Record<string, number> = {
|
||||
read_file: 30_000, // 读取文件 30s
|
||||
write_file: 15_000, // 写入文件 15s
|
||||
edit_file: 15_000, // 编辑文件 15s
|
||||
list_directory: 10_000, // 列目录 10s
|
||||
search_files: 60_000, // 搜索文件 60s
|
||||
create_directory: 5_000, // 创建目录 5s
|
||||
delete_file: 10_000, // 删除文件 10s
|
||||
move_file: 15_000, // 移动文件 15s
|
||||
copy_file: 30_000, // 复制文件 30s(可能大文件)
|
||||
get_file_info: 5_000, // 获取文件信息 5s
|
||||
tree: 15_000, // 目录树 15s
|
||||
diff_files: 15_000, // 文件差异 15s
|
||||
replace_in_files: 30_000, // 批量替换 30s
|
||||
read_multiple_files: 60_000,// 批量读取 60s
|
||||
web_search: 30_000, // 网页搜索 30s
|
||||
web_fetch: 60_000, // 网页抓取 60s
|
||||
download_file: 120_000, // 下载文件 120s
|
||||
git: 30_000, // Git 操作 30s
|
||||
compress: 60_000, // 压缩 60s
|
||||
memory: 5_000, // 记忆操作 5s
|
||||
session_list: 5_000, // 会话列表 5s
|
||||
session_read: 10_000, // 会话读取 10s
|
||||
datetime: 1_000, // 时间查询 1s
|
||||
calculator: 3_000, // 计算器 3s
|
||||
random: 1_000, // 随机数 1s
|
||||
uuid: 1_000, // UUID 1s
|
||||
json_format: 3_000, // JSON 格式化 3s
|
||||
hash: 3_000, // 哈希计算 3s
|
||||
default: 60_000, // 默认 60s
|
||||
/** R3/R108: 工具超时配置(基础超时 + 动态调整) */
|
||||
const TOOL_TIMEOUT_MAP: Record<string, { base: number; grade: 'fast' | 'medium' | 'slow' | 'long'; description: string }> = {
|
||||
// fast (<5秒)
|
||||
datetime: { base: 1_000, grade: 'fast', description: '时间查询' },
|
||||
random: { base: 1_000, grade: 'fast', description: '随机数' },
|
||||
uuid: { base: 1_000, grade: 'fast', description: 'UUID' },
|
||||
calculator: { base: 3_000, grade: 'fast', description: '计算器' },
|
||||
json_format: { base: 3_000, grade: 'fast', description: 'JSON 格式化' },
|
||||
hash: { base: 3_000, grade: 'fast', description: '哈希计算' },
|
||||
create_directory: { base: 5_000, grade: 'fast', description: '创建目录' },
|
||||
get_file_info: { base: 5_000, grade: 'fast', description: '获取文件信息' },
|
||||
memory: { base: 5_000, grade: 'fast', description: '记忆操作' },
|
||||
session_list: { base: 5_000, grade: 'fast', description: '会话列表' },
|
||||
|
||||
// medium (5-15秒)
|
||||
list_directory: { base: 10_000, grade: 'medium', description: '列目录' },
|
||||
delete_file: { base: 10_000, grade: 'medium', description: '删除文件' },
|
||||
write_file: { base: 15_000, grade: 'medium', description: '写入文件' },
|
||||
edit_file: { base: 15_000, grade: 'medium', description: '编辑文件' },
|
||||
move_file: { base: 15_000, grade: 'medium', description: '移动文件' },
|
||||
tree: { base: 15_000, grade: 'medium', description: '目录树' },
|
||||
diff_files: { base: 15_000, grade: 'medium', description: '文件差异' },
|
||||
session_read: { base: 10_000, grade: 'medium', description: '会话读取' },
|
||||
web_search: { base: 30_000, grade: 'medium', description: '网页搜索' },
|
||||
git: { base: 30_000, grade: 'medium', description: 'Git 操作' },
|
||||
|
||||
// slow (15-60秒)
|
||||
read_file: { base: 30_000, grade: 'slow', description: '读取文件' },
|
||||
copy_file: { base: 30_000, grade: 'slow', description: '复制文件' },
|
||||
replace_in_files: { base: 30_000, grade: 'slow', description: '批量替换' },
|
||||
search_files: { base: 60_000, grade: 'slow', description: '搜索文件' },
|
||||
read_multiple_files: { base: 60_000, grade: 'slow', description: '批量读取' },
|
||||
web_fetch: { base: 60_000, grade: 'slow', description: '网页抓取' },
|
||||
compress: { base: 60_000, grade: 'slow', description: '压缩' },
|
||||
|
||||
// long (>60秒)
|
||||
download_file: { base: 120_000, grade: 'long', description: '下载文件' },
|
||||
default: { base: 60_000, grade: 'medium', description: '默认工具' },
|
||||
};
|
||||
|
||||
/** R108: 根据工具参数动态调整超时时间 */
|
||||
function getAdjustedToolTimeout(toolName: string, args: Record<string, unknown>): number {
|
||||
const config = TOOL_TIMEOUT_MAP[toolName] ?? TOOL_TIMEOUT_MAP.default;
|
||||
let timeout = config.base;
|
||||
|
||||
// run_command 特殊处理:根据命令类型调整
|
||||
if (toolName === 'run_command' && args.command) {
|
||||
const cmd = String(args.command);
|
||||
if (cmd.includes('npm install') || cmd.includes('pip install') || cmd.includes('apt-get install')) {
|
||||
timeout = 300_000; // 包安装:5分钟
|
||||
} else if (cmd.includes('build') || cmd.includes('compile') || cmd.includes('make')) {
|
||||
timeout = 180_000; // 编译:3分钟
|
||||
} else if (cmd.includes('test')) {
|
||||
timeout = 120_000; // 测试:2分钟
|
||||
} else {
|
||||
timeout = 120_000; // 默认命令:2分钟
|
||||
}
|
||||
}
|
||||
|
||||
// web_fetch 特殊处理:视频/大型资源
|
||||
if (toolName === 'web_fetch' && args.url) {
|
||||
const url = String(args.url);
|
||||
if (url.includes('youtube') || url.includes('video') || url.includes('mp4')) {
|
||||
timeout = 90_000;
|
||||
}
|
||||
}
|
||||
|
||||
// write_file 特殊处理:大文件
|
||||
if (toolName === 'write_file' && args.content) {
|
||||
const contentLen = String(args.content).length;
|
||||
if (contentLen > 100_000) {
|
||||
timeout = 30_000; // 大文件写入
|
||||
}
|
||||
}
|
||||
|
||||
return timeout;
|
||||
}
|
||||
|
||||
/** R3: 带超时的工具执行包装器 */
|
||||
async function executeToolWithTimeout(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ToolResult> {
|
||||
const timeoutMs = TOOL_TIMEOUT_MAP[toolName] ?? TOOL_TIMEOUT_MAP.default;
|
||||
const timeoutMs = getAdjustedToolTimeout(toolName, args);
|
||||
if (timeoutMs <= 0) {
|
||||
return executeTool(toolName, args);
|
||||
}
|
||||
@@ -1084,9 +1182,15 @@ async function handleInit(
|
||||
): Promise<void> {
|
||||
// 新一轮对话,清空工具缓存
|
||||
toolResultCache.clear();
|
||||
resetAllSafetyState(); // R51-R56: 重置所有安全状态
|
||||
resetTokenBudget(); // R93: 重置 Token 预算追踪
|
||||
setTokenBudgetNumCtx(getEffectiveNumCtx()); // R93: 设置当前预算 numCtx
|
||||
setUserGoal(userContent); // R56: 设置用户原始目标
|
||||
|
||||
const modelSupportsTools = state.get<boolean>('modelSupportsTools', false);
|
||||
const tools = getEnabledToolDefinitions();
|
||||
// R55: 语义工具检索 — 根据用户查询过滤相关工具,减少 token 占用
|
||||
const tools = getRelevantToolDefinitions(userContent);
|
||||
_filteredTools = tools; // 缓存供 handleThinking 使用
|
||||
const useTools = tools.length > 0;
|
||||
|
||||
ctx.messages.length = 0;
|
||||
@@ -1568,7 +1672,7 @@ async function handleThinking(
|
||||
num_ctx: getEffectiveNumCtx(),
|
||||
temperature: state.get<number>('temperature', 0.7)
|
||||
},
|
||||
...(getEnabledToolDefinitions().length > 0 && { tools: getEnabledToolDefinitions() })
|
||||
...(_filteredTools.length > 0 && { tools: _filteredTools })
|
||||
},
|
||||
(chunk: OllamaStreamChunk) => {
|
||||
if (chunk.message?.thinking) {
|
||||
@@ -1710,6 +1814,8 @@ async function handleThinking(
|
||||
if (estimatedThisLoop > 0) {
|
||||
recordActualTokens(ctx.loopPromptEvalCount, ctx.loopEvalCount, estimatedThisLoop, model);
|
||||
}
|
||||
// R93: 记录 Token 预算使用
|
||||
recordBudgetUsage(ctx.loopCount, ctx.loopPromptEvalCount, ctx.loopEvalCount, estimatedThisLoop);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@@ -1845,6 +1951,25 @@ async function handleExecuting(
|
||||
toolResultCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
// R109: 工具参数消毒 — 防止通过工具参数注入恶意内容
|
||||
call.function.arguments = sanitizeToolArgs(call.function.name, call.function.arguments);
|
||||
|
||||
// R113: 命令安全检查 — 对 run_command 进行风险评估
|
||||
if (call.function.name === 'run_command') {
|
||||
const cmdStr = String(call.function.arguments?.command || '');
|
||||
if (cmdStr) {
|
||||
const cmdSafety = checkCommandSafety(cmdStr);
|
||||
if (cmdSafety.riskLevel === 'forbidden') {
|
||||
logWarn(`R113: 命令安全拦截: ${cmdSafety.reason}`);
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: cmdSafety.reason || '命令被安全规则拦截' },
|
||||
status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// P2-4: 工具参数前置校验 — 参数无效直接返回错误,不执行实际工具,节省一轮迭代
|
||||
const paramError = validateToolArgs(call.function.name, call.function.arguments);
|
||||
if (paramError) {
|
||||
@@ -1856,6 +1981,62 @@ async function handleExecuting(
|
||||
}, null];
|
||||
}
|
||||
|
||||
// R77: 工具调用速率限制 — 防止快速连续调用同一工具浪费资源
|
||||
const rateLimitResult = checkRateLimit(call.function.name);
|
||||
if (!rateLimitResult.allowed) {
|
||||
const waitSec = Math.ceil(rateLimitResult.retryAfterMs / 1000);
|
||||
logWarn(`R77: 速率限制拦截: ${call.function.name},需等待 ${waitSec}s`);
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: `工具「${call.function.name}」调用频率过高,请等待 ${waitSec} 秒后重试。` },
|
||||
status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
|
||||
// R87: 工具熔断器检查 — 连续失败后自动禁用工具
|
||||
const circuitBreaker = isToolCircuitBroken(call.function.name);
|
||||
if (circuitBreaker.broken) {
|
||||
const waitSec = Math.ceil(circuitBreaker.remainingMs / 1000);
|
||||
logWarn(`R87: 熔断器拦截: ${call.function.name},冷却中(还需 ${waitSec}s)`);
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: `工具「${call.function.name}」因连续失败已被暂时禁用,请等待 ${waitSec} 秒后重试,或使用其他方法。` },
|
||||
status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
|
||||
// R79: 路径沙箱验证 — 确保文件操作不超出工作空间边界
|
||||
const FILE_PATH_TOOLS = new Set([
|
||||
'read_file', 'write_file', 'edit_file', 'delete_file', 'create_directory',
|
||||
'list_directory', 'search_files', 'get_file_info', 'tree', 'compress',
|
||||
'move_file', 'copy_file', 'replace_in_files', 'download_file',
|
||||
]);
|
||||
if (FILE_PATH_TOOLS.has(call.function.name)) {
|
||||
const workspaceDir = getWorkspaceDirPath();
|
||||
if (workspaceDir) {
|
||||
const pathArg = String(call.function.arguments?.path || call.function.arguments?.source || call.function.arguments?.destination || call.function.arguments?.file1 || '');
|
||||
if (pathArg) {
|
||||
const sandboxResult = validatePathSandbox(pathArg, workspaceDir);
|
||||
if (!sandboxResult.valid) {
|
||||
logWarn(`R79: 路径沙箱拦截: ${call.function.name}(${pathArg}) — ${sandboxResult.reason}`);
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: sandboxResult.reason || '路径不在工作空间范围内' },
|
||||
status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
// R79: 使用标准化路径替换原始路径,防止路径遍历
|
||||
if (sandboxResult.normalizedPath && sandboxResult.normalizedPath !== pathArg) {
|
||||
if (call.function.arguments?.path) {
|
||||
call.function.arguments.path = sandboxResult.normalizedPath;
|
||||
} else if (call.function.arguments?.source) {
|
||||
call.function.arguments.source = sandboxResult.normalizedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// P2-10 修复:queueMicrotask 替代 rAF,在隐藏窗口下不会降频
|
||||
await new Promise<void>(r => { queueMicrotask(r); });
|
||||
callbacks.onToolCallStart(call);
|
||||
@@ -1887,8 +2068,9 @@ async function handleExecuting(
|
||||
}
|
||||
}
|
||||
|
||||
// ── P0-1 修复:区分瞬态错误和永久错误 ──
|
||||
// ── R78: 增强错误分类重试 — 使用 classifyError 区分瞬态/永久/安全错误 ──
|
||||
let lastError = '';
|
||||
let classifiedError: import('./agent-safety.js').ClassifiedError | null = null;
|
||||
for (let retry = 0; retry <= MAX_RETRIES; retry++) {
|
||||
// 重试前检查中止信号
|
||||
if (isAborted()) {
|
||||
@@ -1903,6 +2085,12 @@ async function handleExecuting(
|
||||
state.get<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal)
|
||||
.catch(err => ({ success: false, error: err?.message || String(err) }) as ToolResult);
|
||||
logToolResult(call.function.name, result.success, result.success ? undefined : result.error);
|
||||
// R87: 记录工具成功/失败到熔断器
|
||||
if (result.success) {
|
||||
recordToolSuccess(call.function.name);
|
||||
} else {
|
||||
recordToolFailure(call.function.name);
|
||||
}
|
||||
// 记录 write_file 成功路径及内容指纹,供 FileWriteDedup Hook 做内容级去重
|
||||
if (call.function.name === 'write_file' && result.success && call.function.arguments?.path) {
|
||||
const { addWrittenFile } = await import('./hooks.js');
|
||||
@@ -1914,27 +2102,47 @@ async function handleExecuting(
|
||||
}, result.success ? cacheKey : null];
|
||||
} catch (err) {
|
||||
lastError = (err as Error).message;
|
||||
// 永久性错误不重试:文件不存在、权限拒绝、路径不可达
|
||||
if (isPermanentError(lastError)) {
|
||||
logWarn(`工具永久错误(不重试): ${call.function.name}`, lastError);
|
||||
// R78: 使用错误分类系统替代原有的 isPermanentError
|
||||
classifiedError = classifyError(lastError);
|
||||
|
||||
// 安全错误或永久错误:不重试
|
||||
if (!classifiedError.shouldRetry) {
|
||||
logWarn(`R78: 工具${classifiedError.class}错误(不重试): ${call.function.name}`, classifiedError.userMessage);
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: lastError },
|
||||
result: { success: false, error: classifiedError.userMessage },
|
||||
status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
|
||||
// R78: 检查是否超过最大重试次数
|
||||
if (retry >= (classifiedError.maxRetries || MAX_RETRIES)) {
|
||||
logError(`R78: 工具执行失败(已达最大重试 ${classifiedError.maxRetries}): ${call.function.name}`, lastError);
|
||||
// R97: 记录错误模式并获取改进建议
|
||||
const errorSuggestion = recordErrorPattern(call.function.name, lastError);
|
||||
if (errorSuggestion) {
|
||||
logWarn(`R97: ${errorSuggestion}`);
|
||||
}
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: classifiedError.userMessage },
|
||||
status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
|
||||
if (retry < MAX_RETRIES) {
|
||||
// 瞬态错误:指数退避重试
|
||||
const delay = 500 * Math.pow(2, retry);
|
||||
logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}(${delay}ms 后)`, lastError);
|
||||
// R78: 使用 calculateBackoff 计算指数退避延迟
|
||||
const delay = calculateBackoff(retry, classifiedError.backoffMs);
|
||||
logWarn(`R78: 工具重试 ${retry + 1}/${classifiedError.maxRetries}: ${call.function.name}(${classifiedError.class}错误,${delay}ms 后)`, lastError);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
logError(`工具执行失败(已达最大重试): ${call.function.name}`, lastError);
|
||||
logError(`工具执行失败(已达最大重试): ${call.function.name}`, lastError);
|
||||
// R97: 记录错误模式
|
||||
recordErrorPattern(call.function.name, lastError);
|
||||
return [{
|
||||
name: call.function.name, arguments: call.function.arguments,
|
||||
result: { success: false, error: lastError },
|
||||
status: 'error' as const, timestamp: Date.now()
|
||||
result: { success: false, error: lastError }, status: 'error' as const, timestamp: Date.now()
|
||||
}, null];
|
||||
}
|
||||
}
|
||||
@@ -1962,9 +2170,23 @@ async function handleExecuting(
|
||||
|
||||
for (const [record, cacheKey] of results) {
|
||||
ctx.allToolRecords.push(record);
|
||||
// R88: 为工具结果添加元数据提示(token 估算)
|
||||
const formattedResult = addResultMetadata(formatToolResultForModel(record.name, record.result!));
|
||||
// R104: 检测重复工具结果
|
||||
if (record.status === 'success') {
|
||||
const dedup = isDuplicateToolResult(record.name, formattedResult);
|
||||
if (dedup.duplicate) {
|
||||
logInfo(`R104: 检测到重复工具结果: ${record.name},添加标记`);
|
||||
}
|
||||
}
|
||||
// R92: 工具结果格式标准化 — 添加统一头信息(工具名、状态、耗时、结果大小)
|
||||
const resultDuration = Date.now() - record.timestamp;
|
||||
const resultSize = formattedResult.length;
|
||||
const sizeCategory = resultSize > 10000 ? 'large' : resultSize > 2000 ? 'medium' : 'small';
|
||||
const r92Header = `[工具:${record.name} 状态:${record.status} 耗时:${resultDuration}ms 大小:${sizeCategory}(${resultSize}字符)]`;
|
||||
ctx.messages.push({
|
||||
role: 'tool', tool_name: record.name,
|
||||
content: `<<<TOOL_RESULT_START name="${record.name}">>>\n${formatToolResultForModel(record.name, record.result!)}\n<<<TOOL_RESULT_END>>>`
|
||||
content: `<<<TOOL_RESULT_START name="${record.name}">>>\n${r92Header}\n${formattedResult}\n<<<TOOL_RESULT_END>>>`
|
||||
});
|
||||
// P1-3 优化:移除逐次 nudge 注入,改为 AGENT.md 通用规则
|
||||
// 原逻辑:memory/session_read/session_list/spawn_task 后注入额外 user 消息
|
||||
@@ -2006,13 +2228,15 @@ async function handleObserving(
|
||||
): Promise<void> {
|
||||
// 中止检查
|
||||
if (isAborted()) return;
|
||||
// 硬限制工具消息数量 — 保留最近 80 条(token感知:压力大时自动缩减)
|
||||
// R91: 上下文压力分级评估 — 根据压力等级选择压缩策略
|
||||
const numCtx = getEffectiveNumCtx();
|
||||
const pressureInfo = getContextPressureLevel(ctx.messages, numCtx);
|
||||
{
|
||||
const numCtx = getEffectiveNumCtx();
|
||||
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
|
||||
const pressureRatio = numCtx > 0 ? currentTokens / numCtx : 0;
|
||||
// 压力小时保留 80 条,压力大时缩减到 40 条
|
||||
const maxToolMsgs = pressureRatio > 0.5 ? 40 : 80;
|
||||
// R91: 根据压力等级动态调整工具消息保留数量
|
||||
const maxToolMsgs = pressureInfo.level === 'critical' ? 30
|
||||
: pressureInfo.level === 'high' ? 40
|
||||
: pressureInfo.level === 'medium' ? 60
|
||||
: 80;
|
||||
const toolIndices: number[] = [];
|
||||
for (let i = 0; i < ctx.messages.length; i++) {
|
||||
if (ctx.messages[i].role === 'tool') toolIndices.push(i);
|
||||
@@ -2022,7 +2246,7 @@ async function handleObserving(
|
||||
for (let i = toRemove.length - 1; i >= 0; i--) {
|
||||
ctx.messages.splice(toRemove[i], 1);
|
||||
}
|
||||
logInfo(`工具消息裁剪: ${toRemove.length} 条旧结果已移除 (保留最近 ${maxToolMsgs} 条, 压力 ${(pressureRatio * 100).toFixed(0)}%)`);
|
||||
logInfo(`R91: 工具消息裁剪: ${toRemove.length} 条旧结果已移除 (压力:${pressureInfo.level}, 保留 ${maxToolMsgs} 条)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2034,6 +2258,75 @@ async function handleObserving(
|
||||
// 保存本轮工具调用
|
||||
ctx.prevToolCalls = [...ctx.toolCalls];
|
||||
|
||||
// R52: 状态震荡检测 — 检测 A→B→A→B 模式
|
||||
for (const call of ctx.toolCalls) {
|
||||
recordToolCallHistory(call.function.name, call.function.arguments);
|
||||
}
|
||||
if (detectOscillation()) {
|
||||
logWarn('R52: 检测到工具调用震荡模式(A→B→A→B),注入终止信号');
|
||||
ctx.messages.push({
|
||||
role: 'user',
|
||||
content: '⚠️ 检测到你在两种操作之间来回切换但没有进展。请停下来分析当前状态,换一种策略,或基于已有结果给出最终回答。',
|
||||
ephemeral: true,
|
||||
});
|
||||
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 2);
|
||||
}
|
||||
|
||||
// R54: 增强死循环检测 — 连续 3 次完全相同的工具调用(文档标准)
|
||||
const consecutive = detectConsecutiveIdentical(3);
|
||||
if (consecutive.detected) {
|
||||
logWarn(`R54: 检测到连续 ${consecutive.count} 次相同调用: ${consecutive.toolName},强制终止`);
|
||||
ctx.messages.push({
|
||||
role: 'user',
|
||||
content: `⛔ 检测到连续 ${consecutive.count} 次用完全相同参数调用「${consecutive.toolName}」。这已被系统识别为死循环。立即停止重复调用,基于已有结果给出最终回答。`,
|
||||
});
|
||||
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1);
|
||||
}
|
||||
|
||||
// R53: 主动上下文压缩 — 使用预测系统提前触发压缩
|
||||
{
|
||||
const numCtx = getEffectiveNumCtx();
|
||||
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
|
||||
recordTokenUsage(currentTokens, numCtx);
|
||||
const prediction = predictContextOverflow(numCtx);
|
||||
if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) {
|
||||
logWarn(`R53: 主动压缩触发 — ${prediction.message}`);
|
||||
transition(ctx, S.COMPRESSING);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// R51: 工具结果离线存储 — 将旧的大工具结果精简为引用
|
||||
if (ctx.loopCount > 5 && ctx.loopCount % 3 === 0) {
|
||||
const compactBefore = ctx.messages.length - 10; // 保留最近 10 条不动
|
||||
let compactedCount = 0;
|
||||
for (let i = 0; i < ctx.messages.length && i < compactBefore; i++) {
|
||||
const m = ctx.messages[i];
|
||||
if (m.role === 'tool' && m.content && m.content.length > 1500 && !m.content.startsWith('[工具结果已归档]')) {
|
||||
ctx.messages[i] = compactOldToolResult(m);
|
||||
compactedCount++;
|
||||
}
|
||||
}
|
||||
if (compactedCount > 0) {
|
||||
logInfo(`R51: 工具结果归档: ${compactedCount} 条旧结果已精简为引用`);
|
||||
}
|
||||
}
|
||||
|
||||
// R56: 目标对齐验证 — 每 8 轮检查 Agent 是否偏离用户原始目标
|
||||
if (ctx.loopCount > 0 && ctx.loopCount % 8 === 0) {
|
||||
const recentToolNames = ctx.allToolRecords.slice(-8).map(r => r.name);
|
||||
const recentContent = ctx.messages.slice(-10).map(m => m.content || '').join(' ').slice(0, 2000);
|
||||
const alignment = checkGoalAlignment(recentToolNames, recentContent, ctx.loopCount);
|
||||
if (!alignment.aligned && alignment.suggestion) {
|
||||
logWarn(`R56: 目标对齐检测 — ${alignment.reason}`);
|
||||
ctx.messages.push({
|
||||
role: 'user',
|
||||
content: `[目标对齐提醒] ${alignment.suggestion}`,
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// P2-1: 工具调用失败恢复指引 — 同一工具同一参数连续失败2次以上时注入恢复建议
|
||||
{
|
||||
const errorRecords = ctx.allToolRecords.filter(r => r.status === 'error');
|
||||
@@ -2057,30 +2350,43 @@ async function handleObserving(
|
||||
}
|
||||
}
|
||||
|
||||
// R112: 每 15 轮输出诊断报告 + R100: Token 使用统计报告
|
||||
if (ctx.loopCount > 0 && ctx.loopCount % 15 === 0) {
|
||||
try {
|
||||
const { collectDiagnostics, formatDiagnosticsReport } = await import('./agent-safety.js');
|
||||
const diag = collectDiagnostics();
|
||||
logInfo('R112: 诊断报告\n' + formatDiagnosticsReport(diag));
|
||||
// R100: Token 使用统计报告
|
||||
const tokenReport = generateTokenReport(getEffectiveNumCtx());
|
||||
logInfo('R100: ' + formatTokenReport(tokenReport));
|
||||
} catch { /* 诊断失败不影响主流程 */ }
|
||||
}
|
||||
|
||||
// 记录本轮迭代度量
|
||||
recordIteration(ctx);
|
||||
|
||||
// ── post_iteration Hook ──
|
||||
executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount });
|
||||
|
||||
// ── P1-7: 智能工具结果截断 — 保留 JSON 结构,token 感知软上限 ──
|
||||
// 仅在上下文压力较大时触发(token使用率 > 50%),且截断阈值按当前压力动态调整
|
||||
// R95: 按工具类型智能截断 — 根据压力等级和工具类型选择截断策略
|
||||
if (ctx.loopCount > 10 && ctx.loopCount % 5 === 0) {
|
||||
const numCtx = getEffectiveNumCtx();
|
||||
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
|
||||
const pressureRatio = numCtx > 0 ? currentTokens / numCtx : 0;
|
||||
if (pressureRatio > 0.5) {
|
||||
if (pressureInfo.level === 'high' || pressureInfo.level === 'critical') {
|
||||
// 上下文压力大时,截断旧的工具结果(保留最近15条不动)
|
||||
// 截断阈值按压力动态调整:50%压力=8000字符,80%压力=3000字符
|
||||
const maxLen = Math.max(2000, Math.floor(8000 - (pressureRatio - 0.5) * 10000));
|
||||
// R91: 截断阈值按压力等级动态调整
|
||||
const maxLen = pressureInfo.level === 'critical' ? 3000 : 5000;
|
||||
const truncateBefore = ctx.messages.length - 15;
|
||||
let truncatedCount = 0;
|
||||
for (let i = 0; i < ctx.messages.length && i < truncateBefore; i++) {
|
||||
const m = ctx.messages[i];
|
||||
if (m.role === 'tool' && m.content && m.content.length > maxLen) {
|
||||
ctx.messages[i] = { ...m, content: smartTruncate(m.content, maxLen) };
|
||||
// R95: 使用按工具类型截断替代通用截断
|
||||
ctx.messages[i] = { ...m, content: smartTruncateByToolType(m.tool_name || 'unknown', m.content, maxLen) };
|
||||
truncatedCount++;
|
||||
}
|
||||
}
|
||||
logInfo(`工具结果截断: 压力 ${(pressureRatio * 100).toFixed(0)}%, 阈值 ${maxLen} 字符`);
|
||||
if (truncatedCount > 0) {
|
||||
logInfo(`R95: 工具结果按类型截断: ${truncatedCount} 条, 压力 ${pressureInfo.level}, 阈值 ${maxLen} 字符`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2139,14 +2445,15 @@ async function handleObserving(
|
||||
}
|
||||
}
|
||||
|
||||
// ── P0-4: 增量压缩 — 按token使用率触发,防止 Ollama context 超限 ──
|
||||
const numCtx = getEffectiveNumCtx();
|
||||
const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join(''));
|
||||
const tokenUsageRatio = numCtx > 0 ? currentTokens / numCtx : 0;
|
||||
// 当 token 使用率超过 30% 或消息数超过动态阈值时触发压缩
|
||||
const compressThreshold = getIncrementalCompressThreshold(numCtx);
|
||||
if (tokenUsageRatio > 0.3 || ctx.messages.length >= compressThreshold) {
|
||||
logWarn(`增量压缩触发: token使用率 ${(tokenUsageRatio * 100).toFixed(0)}%, 消息数 ${ctx.messages.length}/${compressThreshold}`);
|
||||
// R96: 消息角色压缩 — 合并连续相同角色消息,减少消息条数开销
|
||||
if (pressureInfo.level === 'medium' || pressureInfo.level === 'high') {
|
||||
ctx.messages = mergeConsecutiveMessages(ctx.messages);
|
||||
}
|
||||
|
||||
// R98: 趋势感知压缩触发 — 结合趋势预测动态调整压缩阈值
|
||||
const compressDecision = getTrendAwareCompressThreshold(numCtx, ctx.messages);
|
||||
if (compressDecision.shouldCompress) {
|
||||
logWarn(`R98: 压缩触发 (${compressDecision.urgency}) — ${compressDecision.reason}`);
|
||||
transition(ctx, S.COMPRESSING);
|
||||
return;
|
||||
}
|
||||
@@ -2340,6 +2647,10 @@ async function handleReflecting(
|
||||
} else {
|
||||
recordCompletionGate(true);
|
||||
}
|
||||
// R90: 记录完成门控评分到日志
|
||||
if (gateResult.score < 100) {
|
||||
logInfo(`R90: Completion Gate 评分: ${gateResult.score}/100`);
|
||||
}
|
||||
} catch { /* Gate 异常不影响主流程 */ }
|
||||
|
||||
logInfo('模型停止工具调用 → ReAct Agent Loop 结束');
|
||||
|
||||
@@ -101,9 +101,72 @@ export function endSessionMetrics(): SessionMetrics | null {
|
||||
`${formatPercent(getToolSuccessRate(metrics))} 成功率`
|
||||
);
|
||||
currentMetrics = null;
|
||||
// R84: 持久化度量历史到 localStorage
|
||||
persistMetricsHistory();
|
||||
return metrics;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R84: 度量持久化 — localStorage 存储与恢复
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const METRICS_STORAGE_KEY = '_metona_agent_metrics';
|
||||
const METRICS_HISTORY_MAX = 100;
|
||||
|
||||
/** R84: 将度量历史保存到 localStorage */
|
||||
function persistMetricsHistory(): void {
|
||||
try {
|
||||
const recent = sessionMetricsHistory.slice(-METRICS_HISTORY_MAX);
|
||||
const serialized = recent.map(m => ({
|
||||
sessionId: m.sessionId,
|
||||
model: m.model,
|
||||
duration: m.endTime - m.startTime,
|
||||
iterations: m.totalIterations,
|
||||
toolCallCount: m.toolCalls.length,
|
||||
toolSuccessRate: getToolSuccessRate(m),
|
||||
completionGatePassed: m.completionGatePassed,
|
||||
errorPatterns: m.errorPatterns,
|
||||
inputTokens: m.totalInputTokens,
|
||||
outputTokens: m.totalOutputTokens,
|
||||
timestamp: m.endTime,
|
||||
}));
|
||||
localStorage.setItem(METRICS_STORAGE_KEY, JSON.stringify(serialized));
|
||||
} catch {
|
||||
// localStorage 满或不可用时静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** R84: 从 localStorage 恢复度量历史 */
|
||||
export function loadMetricsHistory(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(METRICS_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Array<Record<string, any>>;
|
||||
for (const item of parsed) {
|
||||
sessionMetricsHistory.push({
|
||||
sessionId: item.sessionId || 'unknown',
|
||||
model: item.model || 'unknown',
|
||||
startTime: item.timestamp ? item.timestamp - (item.duration || 0) : 0,
|
||||
endTime: item.timestamp || 0,
|
||||
totalIterations: item.iterations || 0,
|
||||
toolCalls: [],
|
||||
totalInputTokens: item.inputTokens || 0,
|
||||
totalOutputTokens: item.outputTokens || 0,
|
||||
completionGatePassed: item.completionGatePassed || false,
|
||||
errorPatterns: item.errorPatterns || [],
|
||||
});
|
||||
}
|
||||
logInfo(`R84: 恢复了 ${sessionMetricsHistory.length} 条历史度量`);
|
||||
} catch {
|
||||
// 解析失败静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** R84: 获取度量历史(供 UI 仪表盘使用) */
|
||||
export function getMetricsHistory(): SessionMetrics[] {
|
||||
return [...sessionMetricsHistory];
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 度量计算
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,32 @@ const contextEfficiencyCheck: CompletionCheck = {
|
||||
},
|
||||
};
|
||||
|
||||
/** R83: Plan Mode 完成检查 — 验证所有计划步骤是否已标记完成 */
|
||||
const planModeCompletionCheck: CompletionCheck = {
|
||||
name: 'planModeCompletion',
|
||||
description: 'Plan Mode 下检查所有计划步骤是否已完成',
|
||||
check: async (ctx: LoopContext) => {
|
||||
if (ctx.mode !== 'plan') return { passed: true, reason: '' };
|
||||
// 从 state 获取 Plan Tracker
|
||||
try {
|
||||
const { getPlanTracker } = await import('./tool-registry.js');
|
||||
const tracker = getPlanTracker();
|
||||
if (!tracker.active || tracker.steps.length === 0) return { passed: true, reason: '' };
|
||||
if (tracker.done < tracker.total) {
|
||||
const remaining = tracker.total - tracker.done;
|
||||
const undoneSteps = tracker.steps.filter(s => !s.done).map(s => s.label).slice(0, 3);
|
||||
return {
|
||||
passed: false,
|
||||
reason: `Plan Mode: 还有 ${remaining} 步未完成(${undoneSteps.join('、')}${remaining > 3 ? '...' : ''})。请继续执行剩余步骤,或说明为什么这些步骤无法完成。`
|
||||
};
|
||||
}
|
||||
return { passed: true, reason: '' };
|
||||
} catch {
|
||||
return { passed: true, reason: '' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 检查项注册与管理
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -114,6 +140,7 @@ const DEFAULT_ENABLED_CHECKS = new Set([
|
||||
'contentQuality',
|
||||
'toolResultReview',
|
||||
'notThinking',
|
||||
'planModeCompletion',
|
||||
]);
|
||||
|
||||
/** 注册表 */
|
||||
@@ -122,6 +149,7 @@ const checkRegistry = new Map<string, CompletionCheck>([
|
||||
['toolResultReview', toolResultReviewCheck],
|
||||
['notThinking', notThinkingCheck],
|
||||
['contextEfficiency', contextEfficiencyCheck],
|
||||
['planModeCompletion', planModeCompletionCheck],
|
||||
]);
|
||||
|
||||
/** 已启用的检查项 */
|
||||
@@ -165,54 +193,104 @@ export function getEnabledChecks(): CompletionCheck[] {
|
||||
export interface GateResult {
|
||||
passed: boolean;
|
||||
reason: string;
|
||||
/** 各项检查的详细结果 */
|
||||
/** R90: 各项检查的详细结果 */
|
||||
details: Array<{ name: string; passed: boolean; reason: string; durationMs: number }>;
|
||||
/** R90/R127: 完成门控评分(0-100) */
|
||||
score: number;
|
||||
/** R90/R127: 评分明细 */
|
||||
scoreBreakdown: Array<{ check: string; passed: boolean; points: number }>;
|
||||
}
|
||||
|
||||
/** R127: 各检查项的权重分配 */
|
||||
const CHECK_WEIGHTS: Record<string, number> = {
|
||||
contentQuality: 30,
|
||||
toolResultReview: 25,
|
||||
notThinking: 20,
|
||||
contextEfficiency: 15,
|
||||
planModeCompletion: 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行完成门控检查
|
||||
* R90: 返回评分结果,评分基于各检查项的权重
|
||||
*/
|
||||
export async function runCompletionGate(ctx: LoopContext): Promise<GateResult> {
|
||||
const checks = getEnabledChecks();
|
||||
if (checks.length === 0) {
|
||||
return { passed: true, reason: '', details: [] };
|
||||
return { passed: true, reason: '', details: [], score: 100, scoreBreakdown: [] };
|
||||
}
|
||||
|
||||
const details: GateResult['details'] = [];
|
||||
const scoreBreakdown: GateResult['scoreBreakdown'] = [];
|
||||
let totalScore = 0;
|
||||
let maxScore = 0;
|
||||
|
||||
for (const check of checks) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await check.check(ctx);
|
||||
const durationMs = Date.now() - start;
|
||||
const weight = CHECK_WEIGHTS[check.name] ?? 10;
|
||||
maxScore += weight;
|
||||
|
||||
details.push({
|
||||
name: check.name,
|
||||
passed: result.passed,
|
||||
reason: result.reason,
|
||||
durationMs: Date.now() - start,
|
||||
durationMs,
|
||||
});
|
||||
if (!result.passed) {
|
||||
|
||||
scoreBreakdown.push({
|
||||
check: check.name,
|
||||
passed: result.passed,
|
||||
points: result.passed ? weight : 0,
|
||||
});
|
||||
|
||||
if (result.passed) {
|
||||
totalScore += weight;
|
||||
} else {
|
||||
logWarn(`Completion Gate: "${check.name}" — ${result.reason}`);
|
||||
return { passed: false, reason: result.reason, details };
|
||||
return {
|
||||
passed: false,
|
||||
reason: result.reason,
|
||||
details,
|
||||
score: Math.round((totalScore / Math.max(1, maxScore)) * 100),
|
||||
scoreBreakdown,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const weight = CHECK_WEIGHTS[check.name] ?? 10;
|
||||
maxScore += weight;
|
||||
details.push({
|
||||
name: check.name,
|
||||
passed: true,
|
||||
reason: `检查异常: ${(err as Error).message}`,
|
||||
durationMs: Date.now() - start,
|
||||
});
|
||||
scoreBreakdown.push({
|
||||
check: check.name,
|
||||
passed: true,
|
||||
points: weight,
|
||||
});
|
||||
totalScore += weight;
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: true, reason: '', details };
|
||||
return {
|
||||
passed: true,
|
||||
reason: '',
|
||||
details,
|
||||
score: Math.round((totalScore / Math.max(1, maxScore)) * 100),
|
||||
scoreBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成门控报告(供日志/调试使用)
|
||||
* R127: 生成门控报告(供日志/调试使用)
|
||||
*/
|
||||
export function formatGateReport(result: GateResult): string {
|
||||
const status = result.passed ? '✅ 通过' : '❌ 未通过';
|
||||
let report = `Completion Gate ${status}`;
|
||||
let report = `Completion Gate ${status} (评分: ${result.score}/100)`;
|
||||
if (!result.passed) {
|
||||
report += ` — ${result.reason}`;
|
||||
}
|
||||
@@ -221,5 +299,12 @@ export function formatGateReport(result: GateResult): string {
|
||||
const icon = d.passed ? '✅' : '❌';
|
||||
report += `\n${icon} ${d.name}: ${d.reason || '通过'} (${d.durationMs}ms)`;
|
||||
}
|
||||
// R127: 评分明细
|
||||
if (result.scoreBreakdown.length > 0) {
|
||||
report += `\n${'─'.repeat(40)}\n评分明细:`;
|
||||
for (const s of result.scoreBreakdown) {
|
||||
report += `\n ${s.passed ? '✅' : '❌'} ${s.check}: ${s.points} 分`;
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
@@ -903,7 +903,13 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM
|
||||
return [...systemMsgs, ...recentMsgs];
|
||||
}
|
||||
|
||||
// 按重要性降序排列,取能装下的最大数量
|
||||
// R94: 按综合评分降序排列(重要性 + 时近性),取能装下的最大数量
|
||||
const totalOlder = olderMsgs.length;
|
||||
scored.forEach((s, idx) => {
|
||||
// R94: 时近性因子 — 越靠近最近窗口的消息得分越高(0~2 分加成)
|
||||
const recencyRatio = totalOlder > 1 ? idx / (totalOlder - 1) : 1;
|
||||
s.importance += Math.round(recencyRatio * 2);
|
||||
});
|
||||
scored.sort((a, b) => b.importance - a.importance);
|
||||
|
||||
let usedTokens = 0;
|
||||
@@ -924,3 +930,959 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R91: 上下文压力分级评估 — 三级压力系统指导压缩策略选择
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export type ContextPressureLevel = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export interface ContextPressureInfo {
|
||||
level: ContextPressureLevel;
|
||||
tokenUsageRatio: number; // 0-1
|
||||
messageCount: number;
|
||||
recommendedActions: string[]; // 建议的压缩动作
|
||||
}
|
||||
|
||||
/**
|
||||
* R91: 评估当前上下文压力等级
|
||||
* - low (<30%): 无需压缩
|
||||
* - medium (30-50%): 轻量压缩(归档旧工具结果、清理 ephemeral)
|
||||
* - high (50-70%): 中等压缩(截断工具结果、合并消息)
|
||||
* - critical (>70%): LLM 压缩
|
||||
*/
|
||||
export function getContextPressureLevel(
|
||||
messages: OllamaMessage[],
|
||||
numCtx: number,
|
||||
): ContextPressureInfo {
|
||||
const totalTokens = messages.reduce((sum, m) => {
|
||||
let t = estimateTokens(m.content || '');
|
||||
if (m.tool_calls?.length) {
|
||||
for (const tc of m.tool_calls) {
|
||||
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
|
||||
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
|
||||
}
|
||||
}
|
||||
if (m.images?.length) t += m.images.length * 100;
|
||||
return sum + t;
|
||||
}, 0);
|
||||
|
||||
const ratio = numCtx > 0 ? totalTokens / numCtx : 0;
|
||||
const msgCount = messages.length;
|
||||
const actions: string[] = [];
|
||||
|
||||
let level: ContextPressureLevel;
|
||||
if (ratio > 0.7) {
|
||||
level = 'critical';
|
||||
actions.push('llm_compress', 'truncate_results', 'compact_old', 'merge_messages', 'clear_ephemeral');
|
||||
} else if (ratio > 0.5) {
|
||||
level = 'high';
|
||||
actions.push('truncate_results', 'compact_old', 'merge_messages');
|
||||
} else if (ratio > 0.3) {
|
||||
level = 'medium';
|
||||
actions.push('compact_old', 'clear_ephemeral');
|
||||
} else {
|
||||
level = 'low';
|
||||
if (msgCount > 60) actions.push('compact_old');
|
||||
}
|
||||
|
||||
return { level, tokenUsageRatio: ratio, messageCount: msgCount, recommendedActions: actions };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R93: Token 预算追踪器 — 实时追踪输入/输出 token 与预算比例
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
interface TokenBudgetEntry {
|
||||
loop: number;
|
||||
inputTokens: number; // prompt_eval_count
|
||||
outputTokens: number; // eval_count
|
||||
estimatedTokens: number; // 本地估算值
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const _tokenBudgetHistory: TokenBudgetEntry[] = [];
|
||||
const MAX_BUDGET_ENTRIES = 50;
|
||||
let _totalInputTokens = 0;
|
||||
let _totalOutputTokens = 0;
|
||||
let _budgetNumCtx = 131072;
|
||||
|
||||
/** R93: 设置当前预算的 numCtx */
|
||||
export function setTokenBudgetNumCtx(numCtx: number): void {
|
||||
_budgetNumCtx = numCtx;
|
||||
}
|
||||
|
||||
/** R93: 记录一轮的 token 消耗 */
|
||||
export function recordBudgetUsage(
|
||||
loop: number,
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
estimatedTokens: number,
|
||||
): void {
|
||||
_tokenBudgetHistory.push({
|
||||
loop, inputTokens, outputTokens, estimatedTokens, timestamp: Date.now(),
|
||||
});
|
||||
if (_tokenBudgetHistory.length > MAX_BUDGET_ENTRIES) {
|
||||
_tokenBudgetHistory.shift();
|
||||
}
|
||||
_totalInputTokens += inputTokens;
|
||||
_totalOutputTokens += outputTokens;
|
||||
}
|
||||
|
||||
/** R93: 获取 Token 预算使用情况 */
|
||||
export interface TokenBudgetStatus {
|
||||
totalInput: number;
|
||||
totalOutput: number;
|
||||
totalSpent: number;
|
||||
avgInputPerLoop: number;
|
||||
avgOutputPerLoop: number;
|
||||
budgetNumCtx: number;
|
||||
currentLoopInput: number;
|
||||
budgetUtilization: number; // 当前轮输入占预算比例 0-1
|
||||
trend: 'increasing' | 'stable' | 'decreasing';
|
||||
history: TokenBudgetEntry[];
|
||||
}
|
||||
|
||||
/** R93: 获取当前 Token 预算状态 */
|
||||
export function getTokenBudgetStatus(): TokenBudgetStatus {
|
||||
const history = [..._tokenBudgetHistory];
|
||||
const currentLoop = history.length > 0 ? history[history.length - 1] : null;
|
||||
|
||||
// 计算趋势
|
||||
let trend: 'increasing' | 'stable' | 'decreasing' = 'stable';
|
||||
if (history.length >= 3) {
|
||||
const recent = history.slice(-3);
|
||||
const avg = recent.reduce((s, e) => s + e.inputTokens, 0) / recent.length;
|
||||
const oldest = recent[0].inputTokens;
|
||||
if (avg > oldest * 1.15) trend = 'increasing';
|
||||
else if (avg < oldest * 0.85) trend = 'decreasing';
|
||||
}
|
||||
|
||||
const avgInput = history.length > 0
|
||||
? Math.round(_totalInputTokens / history.length)
|
||||
: 0;
|
||||
const avgOutput = history.length > 0
|
||||
? Math.round(_totalOutputTokens / history.length)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalInput: _totalInputTokens,
|
||||
totalOutput: _totalOutputTokens,
|
||||
totalSpent: _totalInputTokens + _totalOutputTokens,
|
||||
avgInputPerLoop: avgInput,
|
||||
avgOutputPerLoop: avgOutput,
|
||||
budgetNumCtx: _budgetNumCtx,
|
||||
currentLoopInput: currentLoop?.inputTokens || 0,
|
||||
budgetUtilization: _budgetNumCtx > 0 && currentLoop
|
||||
? currentLoop.inputTokens / _budgetNumCtx
|
||||
: 0,
|
||||
trend,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
/** R93: 重置 Token 预算追踪 */
|
||||
export function resetTokenBudget(): void {
|
||||
_tokenBudgetHistory.length = 0;
|
||||
_totalInputTokens = 0;
|
||||
_totalOutputTokens = 0;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R96: 消息角色压缩 — 合并连续相同角色消息,减少消息条数开销
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* R96: 合并连续相同角色的非工具消息
|
||||
* 规则:
|
||||
* - 连续的 user 消息合并为一条(用分隔符连接)
|
||||
* - 连续的 assistant 消息合并为一条(保留 tool_calls)
|
||||
* - tool 消息不合并(每条对应一个 tool_call)
|
||||
* - system 消息不合并(已有 R17 处理)
|
||||
* - ephemeral 临时消息不合并
|
||||
* - compressed 消息不合并
|
||||
*/
|
||||
export function mergeConsecutiveMessages(messages: OllamaMessage[]): OllamaMessage[] {
|
||||
if (messages.length <= 2) return messages;
|
||||
|
||||
const result: OllamaMessage[] = [];
|
||||
let mergedCount = 0;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const last = result[result.length - 1];
|
||||
|
||||
// 不合并的情况
|
||||
if (
|
||||
!last ||
|
||||
msg.role === 'tool' ||
|
||||
msg.role === 'system' ||
|
||||
msg.ephemeral ||
|
||||
msg.compressed ||
|
||||
last.role !== msg.role ||
|
||||
last.ephemeral ||
|
||||
last.compressed ||
|
||||
msg.tool_calls?.length || // 有工具调用的 assistant 不合并
|
||||
last.tool_calls?.length
|
||||
) {
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 合并连续相同角色消息
|
||||
// 限制合并后内容不超过 3000 字符,避免合并后过长
|
||||
const combinedContent = (last.content || '') + '\n\n' + (msg.content || '');
|
||||
if (combinedContent.length > 3000) {
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
result[result.length - 1] = {
|
||||
...last,
|
||||
content: combinedContent,
|
||||
};
|
||||
mergedCount++;
|
||||
}
|
||||
|
||||
if (mergedCount > 0) {
|
||||
logInfo(`R96: 消息角色压缩 — 合并了 ${mergedCount} 条连续同角色消息 (${messages.length} → ${result.length})`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R98: 压缩触发阈值优化 — 结合趋势预测动态调整
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* R98: 获取结合趋势的压缩触发阈值
|
||||
* 如果 token 使用趋势在快速增长,提前触发压缩
|
||||
* 如果趋势稳定或下降,延后压缩
|
||||
*/
|
||||
export function getTrendAwareCompressThreshold(
|
||||
numCtx: number,
|
||||
messages: OllamaMessage[],
|
||||
): { shouldCompress: boolean; reason: string; urgency: 'low' | 'medium' | 'high' } {
|
||||
const baseThreshold = getAdaptiveCompressThreshold(numCtx);
|
||||
const currentTokens = messages.reduce((sum, m) => {
|
||||
let t = estimateTokens(m.content || '');
|
||||
if (m.tool_calls?.length) {
|
||||
for (const tc of m.tool_calls) {
|
||||
const argsSize = JSON.stringify(tc.function.arguments || {}).length;
|
||||
t += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20;
|
||||
}
|
||||
}
|
||||
if (m.images?.length) t += m.images.length * 100;
|
||||
return sum + t;
|
||||
}, 0);
|
||||
|
||||
const usageRatio = numCtx > 0 ? currentTokens / numCtx : 0;
|
||||
const prediction = predictContextOverflow(numCtx);
|
||||
|
||||
// 紧急情况:预测即将溢出
|
||||
if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) {
|
||||
return {
|
||||
shouldCompress: true,
|
||||
reason: `趋势预测触发: ${prediction.message}`,
|
||||
urgency: 'high',
|
||||
};
|
||||
}
|
||||
|
||||
// 趋势加速增长 + 使用率超过基础阈值
|
||||
if (prediction.turnsToOverflow > 0 && prediction.turnsToOverflow <= 5 && usageRatio > baseThreshold * 0.8) {
|
||||
return {
|
||||
shouldCompress: true,
|
||||
reason: `趋势加速: ${prediction.turnsToOverflow} 轮后可能溢出,当前使用率 ${(usageRatio * 100).toFixed(0)}%`,
|
||||
urgency: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
// 标准阈值触发
|
||||
if (usageRatio > baseThreshold) {
|
||||
return {
|
||||
shouldCompress: true,
|
||||
reason: `标准阈值触发: 使用率 ${(usageRatio * 100).toFixed(0)}% > 阈值 ${(baseThreshold * 100).toFixed(0)}%`,
|
||||
urgency: usageRatio > 0.6 ? 'high' : 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
// 消息条数硬阈值
|
||||
const msgThreshold = getIncrementalCompressThresholdMessages(numCtx);
|
||||
if (messages.length >= msgThreshold) {
|
||||
return {
|
||||
shouldCompress: true,
|
||||
reason: `消息条数触发: ${messages.length} >= ${msgThreshold}`,
|
||||
urgency: 'low',
|
||||
};
|
||||
}
|
||||
|
||||
return { shouldCompress: false, reason: '', urgency: 'low' };
|
||||
}
|
||||
|
||||
/** R98: 消息条数阈值(独立函数,供 engine 复用) */
|
||||
function getIncrementalCompressThresholdMessages(numCtx: number): number {
|
||||
const tokenThreshold = Math.floor(numCtx * 0.3);
|
||||
return Math.max(20, Math.min(120, Math.floor(tokenThreshold / 100)));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R100: Token 使用统计报告 — 生成详细消耗分析
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface TokenReport {
|
||||
generatedAt: number;
|
||||
session: {
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalTokens: number;
|
||||
loopCount: number;
|
||||
avgInputPerLoop: number;
|
||||
avgOutputPerLoop: number;
|
||||
};
|
||||
budget: {
|
||||
numCtx: number;
|
||||
currentUtilization: number;
|
||||
peakUtilization: number;
|
||||
trend: 'increasing' | 'stable' | 'decreasing';
|
||||
};
|
||||
compression: {
|
||||
historyCount: number;
|
||||
avgCompressionRatio: number;
|
||||
lastCompressionRatio: number | null;
|
||||
};
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* R100: 生成 Token 使用统计报告
|
||||
*/
|
||||
export function generateTokenReport(numCtx: number): TokenReport {
|
||||
const budget = getTokenBudgetStatus();
|
||||
const compressionHistory = getCompressionHistory();
|
||||
|
||||
// 计算峰值利用率
|
||||
let peakUtilization = 0;
|
||||
for (const entry of budget.history) {
|
||||
const util = numCtx > 0 ? entry.inputTokens / numCtx : 0;
|
||||
if (util > peakUtilization) peakUtilization = util;
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
if (budget.budgetUtilization > 0.7) {
|
||||
warnings.push(`当前轮 token 使用率过高: ${(budget.budgetUtilization * 100).toFixed(0)}%`);
|
||||
}
|
||||
if (budget.trend === 'increasing' && budget.avgInputPerLoop > numCtx * 0.3) {
|
||||
warnings.push(`token 消耗趋势上升,平均每轮 ${budget.avgInputPerLoop} tokens`);
|
||||
}
|
||||
if (compressionHistory.length > 0) {
|
||||
const avgRatio = getAverageCompressionRatio();
|
||||
if (avgRatio > 0.8) {
|
||||
warnings.push(`压缩效率偏低: 平均压缩率 ${(avgRatio * 100).toFixed(0)}% (越低越好)`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: Date.now(),
|
||||
session: {
|
||||
totalInputTokens: budget.totalInput,
|
||||
totalOutputTokens: budget.totalOutput,
|
||||
totalTokens: budget.totalSpent,
|
||||
loopCount: budget.history.length,
|
||||
avgInputPerLoop: budget.avgInputPerLoop,
|
||||
avgOutputPerLoop: budget.avgOutputPerLoop,
|
||||
},
|
||||
budget: {
|
||||
numCtx,
|
||||
currentUtilization: budget.budgetUtilization,
|
||||
peakUtilization,
|
||||
trend: budget.trend,
|
||||
},
|
||||
compression: {
|
||||
historyCount: compressionHistory.length,
|
||||
avgCompressionRatio: getAverageCompressionRatio(),
|
||||
lastCompressionRatio: compressionHistory.length > 0
|
||||
? compressionHistory[compressionHistory.length - 1].compressionRatio
|
||||
: null,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/** R100: 格式化 Token 报告为可读字符串 */
|
||||
export function formatTokenReport(report: TokenReport): string {
|
||||
const lines: string[] = [
|
||||
`Token Usage Report (${new Date(report.generatedAt).toLocaleTimeString()})`,
|
||||
`${'─'.repeat(50)}`,
|
||||
`Session:`,
|
||||
` Total Input: ${report.session.totalInputTokens.toLocaleString()} tokens`,
|
||||
` Total Output: ${report.session.totalOutputTokens.toLocaleString()} tokens`,
|
||||
` Total Spent: ${report.session.totalTokens.toLocaleString()} tokens`,
|
||||
` Loops: ${report.session.loopCount}`,
|
||||
` Avg In/Loop: ${report.session.avgInputPerLoop.toLocaleString()} tokens`,
|
||||
` Avg Out/Loop: ${report.session.avgOutputPerLoop.toLocaleString()} tokens`,
|
||||
`Budget:`,
|
||||
` numCtx: ${report.budget.numCtx.toLocaleString()}`,
|
||||
` Current Usage: ${(report.budget.currentUtilization * 100).toFixed(1)}%`,
|
||||
` Peak Usage: ${(report.budget.peakUtilization * 100).toFixed(1)}%`,
|
||||
` Trend: ${report.budget.trend}`,
|
||||
`Compression:`,
|
||||
` History Count: ${report.compression.historyCount}`,
|
||||
` Avg Ratio: ${(report.compression.avgCompressionRatio * 100).toFixed(0)}%`,
|
||||
` Last Ratio: ${report.compression.lastCompressionRatio !== null ? (report.compression.lastCompressionRatio * 100).toFixed(0) + '%' : 'N/A'}`,
|
||||
];
|
||||
if (report.warnings.length > 0) {
|
||||
lines.push(`Warnings:`);
|
||||
for (const w of report.warnings) {
|
||||
lines.push(` ⚠️ ${w}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R111: 压缩策略自适应选择 — 根据上下文特征选择快速压缩或 LLM 压缩
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export type CompressionStrategy = 'skip' | 'fast' | 'medium' | 'llm';
|
||||
|
||||
export interface CompressionDecision {
|
||||
strategy: CompressionStrategy;
|
||||
reason: string;
|
||||
estimatedSavings: number; // 预估节省 token 数
|
||||
}
|
||||
|
||||
/** R111: 根据上下文压力和消息特征选择最优压缩策略 */
|
||||
export function chooseCompressionStrategy(
|
||||
messages: OllamaMessage[],
|
||||
numCtx: number,
|
||||
pressureLevel: string
|
||||
): CompressionDecision {
|
||||
const totalTokens = estimateTokens(messages.map(m => m.content || '').join(''));
|
||||
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
|
||||
|
||||
// R120: 如果上下文压力很低且消息不多,跳过压缩
|
||||
if (pressureLevel === 'low' && messages.length < 30) {
|
||||
return {
|
||||
strategy: 'skip',
|
||||
reason: `上下文压力低 (${messages.length} 条消息, ${(usageRatio * 100).toFixed(0)}%),无需压缩`,
|
||||
estimatedSavings: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 统计工具结果消息占比
|
||||
const toolMsgs = messages.filter(m => m.role === 'tool');
|
||||
const toolRatio = messages.length > 0 ? toolMsgs.length / messages.length : 0;
|
||||
|
||||
// 如果工具结果占比高,使用快速压缩(截断+归档)
|
||||
if (toolRatio > 0.4 && pressureLevel !== 'critical') {
|
||||
const savings = Math.floor(totalTokens * 0.3);
|
||||
return {
|
||||
strategy: 'fast',
|
||||
reason: `工具结果占比高 (${(toolRatio * 100).toFixed(0)}%),使用快速截断压缩`,
|
||||
estimatedSavings: savings,
|
||||
};
|
||||
}
|
||||
|
||||
// 中等压力:中等压缩(消息合并+旧消息裁剪)
|
||||
if (pressureLevel === 'medium' || pressureLevel === 'high') {
|
||||
const savings = Math.floor(totalTokens * 0.4);
|
||||
return {
|
||||
strategy: 'medium',
|
||||
reason: `中等压力 (${pressureLevel}),使用消息合并+裁剪`,
|
||||
estimatedSavings: savings,
|
||||
};
|
||||
}
|
||||
|
||||
// 关键压力或高使用率:使用 LLM 摘要压缩
|
||||
if (pressureLevel === 'critical' || usageRatio > 0.75) {
|
||||
const savings = Math.floor(totalTokens * 0.6);
|
||||
return {
|
||||
strategy: 'llm',
|
||||
reason: `高压力 (${pressureLevel}, ${(usageRatio * 100).toFixed(0)}%),使用 LLM 摘要压缩`,
|
||||
estimatedSavings: savings,
|
||||
};
|
||||
}
|
||||
|
||||
// 默认:快速压缩
|
||||
return {
|
||||
strategy: 'fast',
|
||||
reason: '默认快速压缩',
|
||||
estimatedSavings: Math.floor(totalTokens * 0.2),
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R115: 上下文水印 — 标记不可压缩的关键信息
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 水印标记:带有此标记的消息在压缩时会被保留 */
|
||||
const WATERMARK_PREFIX = '[PRESERVE]';
|
||||
const _watermarkedIndices = new Set<number>();
|
||||
|
||||
/** R115: 标记消息为不可压缩 */
|
||||
export function watermarkMessage(index: number): void {
|
||||
_watermarkedIndices.add(index);
|
||||
}
|
||||
|
||||
/** R115: 检查消息是否被水印保护 */
|
||||
export function isWatermarked(index: number): boolean {
|
||||
return _watermarkedIndices.has(index);
|
||||
}
|
||||
|
||||
/** R115: 自动为关键消息添加水印 */
|
||||
export function autoWatermarkCritical(messages: OllamaMessage[]): number[] {
|
||||
const protectedIndices: number[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const content = msg.content || '';
|
||||
|
||||
// 系统消息始终保护
|
||||
if (msg.role === 'system') {
|
||||
watermarkMessage(i);
|
||||
protectedIndices.push(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 包含错误信息的用户消息保护
|
||||
if (msg.role === 'user' && (content.includes('错误') || content.includes('error') || content.includes('失败'))) {
|
||||
watermarkMessage(i);
|
||||
protectedIndices.push(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 最近 5 条消息保护
|
||||
if (i >= messages.length - 5) {
|
||||
watermarkMessage(i);
|
||||
protectedIndices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return protectedIndices;
|
||||
}
|
||||
|
||||
/** R115: 清除水印 */
|
||||
export function clearWatermarks(): void {
|
||||
_watermarkedIndices.clear();
|
||||
}
|
||||
|
||||
/** R115: 获取受保护的消息索引列表 */
|
||||
export function getWatermarkedIndices(): number[] {
|
||||
return Array.from(_watermarkedIndices).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R120: 上下文压缩跳过逻辑 — 不值得压缩时跳过
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** R120: 判断是否应该跳过压缩 */
|
||||
export function shouldSkipCompression(
|
||||
messages: OllamaMessage[],
|
||||
numCtx: number,
|
||||
recentCompressionRatio: number
|
||||
): { skip: boolean; reason: string } {
|
||||
const totalTokens = estimateTokens(messages.map(m => m.content || '').join(''));
|
||||
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
|
||||
|
||||
// 如果使用率很低,跳过
|
||||
if (usageRatio < 0.2) {
|
||||
return { skip: true, reason: `上下文使用率极低 (${(usageRatio * 100).toFixed(0)}%),无需压缩` };
|
||||
}
|
||||
|
||||
// 如果消息数太少,跳过
|
||||
if (messages.length < 10) {
|
||||
return { skip: true, reason: `消息数过少 (${messages.length} 条),无需压缩` };
|
||||
}
|
||||
|
||||
// 如果最近压缩收益很低(压缩比 < 10%),跳过
|
||||
if (recentCompressionRatio > 0.9) {
|
||||
return { skip: true, reason: `最近压缩收益低 (压缩比 ${(recentCompressionRatio * 100).toFixed(0)}%),跳过` };
|
||||
}
|
||||
|
||||
// 如果大部分消息已经被归档/压缩过,跳过
|
||||
const archivedCount = messages.filter(m =>
|
||||
m.content?.includes('[工具结果已归档]') || m.content?.includes('[PRESERVE]')
|
||||
).length;
|
||||
if (archivedCount / messages.length > 0.6) {
|
||||
return { skip: true, reason: `大部分消息已归档 (${(archivedCount / messages.length * 100).toFixed(0)}%),跳过` };
|
||||
}
|
||||
|
||||
return { skip: false, reason: '' };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R121: 滑动窗口自适应大小 — 根据上下文压力动态调整窗口大小
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** R121: 根据上下文压力获取自适应滑动窗口大小 */
|
||||
export function getAdaptiveWindowSize(
|
||||
totalMessages: number,
|
||||
pressureLevel: string,
|
||||
numCtx: number
|
||||
): { keepRecent: number; keepSystem: number; reason: string } {
|
||||
const baseWindow = Math.min(totalMessages, 40);
|
||||
|
||||
switch (pressureLevel) {
|
||||
case 'critical':
|
||||
return {
|
||||
keepRecent: Math.min(baseWindow, 15),
|
||||
keepSystem: 2,
|
||||
reason: '关键压力:保留最近 15 条 + 系统 2 条',
|
||||
};
|
||||
case 'high':
|
||||
return {
|
||||
keepRecent: Math.min(baseWindow, 25),
|
||||
keepSystem: 3,
|
||||
reason: '高压力:保留最近 25 条 + 系统 3 条',
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
keepRecent: Math.min(baseWindow, 35),
|
||||
keepSystem: 5,
|
||||
reason: '中等压力:保留最近 35 条 + 系统 5 条',
|
||||
};
|
||||
case 'low':
|
||||
default:
|
||||
return {
|
||||
keepRecent: Math.min(baseWindow, 50),
|
||||
keepSystem: 5,
|
||||
reason: '低压力:保留最近 50 条 + 系统 5 条',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R122: Token 趋势分析 — 深度分析 token 使用趋势用于预测性压缩
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface TrendAnalysis {
|
||||
trend: 'increasing' | 'decreasing' | 'stable';
|
||||
avgGrowthRate: number; // 每轮平均 token 增长量
|
||||
projectedOverflow: number; // 预计几轮后溢出(-1=不会)
|
||||
recommendedAction: string;
|
||||
confidence: number; // 0-1
|
||||
}
|
||||
|
||||
/** R122: 分析 token 使用趋势 */
|
||||
export function analyzeTokenTrend(numCtx: number): TrendAnalysis {
|
||||
if (_tokenUsageTrend.length < 3) {
|
||||
return {
|
||||
trend: 'stable',
|
||||
avgGrowthRate: 0,
|
||||
projectedOverflow: -1,
|
||||
recommendedAction: '数据不足,暂不推荐操作',
|
||||
confidence: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const points = _tokenUsageTrend;
|
||||
const n = points.length;
|
||||
|
||||
// 计算平均增长率
|
||||
let totalGrowth = 0;
|
||||
let growthCount = 0;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const growth = points[i].tokens - points[i - 1].tokens;
|
||||
totalGrowth += growth;
|
||||
growthCount++;
|
||||
}
|
||||
const avgGrowthRate = growthCount > 0 ? totalGrowth / growthCount : 0;
|
||||
|
||||
// 线性回归确定趋势
|
||||
const xs = points.map(p => p.turn);
|
||||
const ys = points.map(p => p.tokens);
|
||||
const xMean = xs.reduce((s, x) => s + x, 0) / n;
|
||||
const yMean = ys.reduce((s, y) => s + y, 0) / n;
|
||||
let num = 0, den = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
num += (xs[i] - xMean) * (ys[i] - yMean);
|
||||
den += (xs[i] - xMean) ** 2;
|
||||
}
|
||||
const slope = den !== 0 ? num / den : 0;
|
||||
|
||||
// 判断趋势
|
||||
let trend: TrendAnalysis['trend'];
|
||||
if (slope > 100) trend = 'increasing';
|
||||
else if (slope < -50) trend = 'decreasing';
|
||||
else trend = 'stable';
|
||||
|
||||
// 预测溢出
|
||||
let projectedOverflow = -1;
|
||||
if (slope > 0) {
|
||||
const currentTokens = points[n - 1].tokens;
|
||||
const remaining = numCtx - currentTokens;
|
||||
projectedOverflow = Math.ceil(remaining / slope);
|
||||
if (projectedOverflow < 0) projectedOverflow = 0;
|
||||
}
|
||||
|
||||
// 推荐操作
|
||||
let recommendedAction = '';
|
||||
if (trend === 'increasing' && projectedOverflow >= 0 && projectedOverflow <= 5) {
|
||||
recommendedAction = `⚠️ 预计 ${projectedOverflow} 轮后上下文溢出,建议立即压缩`;
|
||||
} else if (trend === 'increasing' && projectedOverflow > 5 && projectedOverflow <= 10) {
|
||||
recommendedAction = `建议在接下来 2-3 轮内进行压缩(${projectedOverflow} 轮后溢出)`;
|
||||
} else if (trend === 'stable') {
|
||||
recommendedAction = 'Token 使用趋势稳定,无需额外操作';
|
||||
} else if (trend === 'decreasing') {
|
||||
recommendedAction = 'Token 使用量在下降,压缩策略生效';
|
||||
}
|
||||
|
||||
// 置信度:基于数据点数量和趋势一致性
|
||||
let confidence = Math.min(1, n / 10);
|
||||
if (trend === 'stable') confidence *= 0.7;
|
||||
|
||||
return {
|
||||
trend,
|
||||
avgGrowthRate: Math.round(avgGrowthRate),
|
||||
projectedOverflow,
|
||||
recommendedAction,
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R123: 会话摘要持久化 — 跨会话引用
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
goal: string;
|
||||
summary: string;
|
||||
toolsUsed: string[];
|
||||
keyFindings: string[];
|
||||
tokenUsage: number;
|
||||
}
|
||||
|
||||
const SESSION_SUMMARY_KEY = 'metona_session_summaries';
|
||||
const MAX_SESSION_SUMMARIES = 10;
|
||||
|
||||
/** R123: 保存会话摘要到 localStorage */
|
||||
export function saveSessionSummary(summary: SessionSummary): void {
|
||||
try {
|
||||
const existing = loadSessionSummaries();
|
||||
existing.unshift(summary);
|
||||
if (existing.length > MAX_SESSION_SUMMARIES) {
|
||||
existing.length = MAX_SESSION_SUMMARIES;
|
||||
}
|
||||
localStorage.setItem(SESSION_SUMMARY_KEY, JSON.stringify(existing));
|
||||
logInfo(`R123: 会话摘要已保存 (${summary.id})`);
|
||||
} catch (err) {
|
||||
logWarn(`R123: 保存会话摘要失败: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** R123: 加载所有会话摘要 */
|
||||
export function loadSessionSummaries(): SessionSummary[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_SUMMARY_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as SessionSummary[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** R123: 生成当前会话摘要 */
|
||||
export function generateSessionSummary(
|
||||
goal: string,
|
||||
messages: OllamaMessage[],
|
||||
toolRecords: Array<{ name: string }>,
|
||||
totalTokens: number
|
||||
): SessionSummary {
|
||||
const toolsUsed = [...new Set(toolRecords.map(t => t.name))];
|
||||
const assistantMessages = messages.filter(m => m.role === 'assistant');
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
||||
|
||||
return {
|
||||
id: `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
createdAt: Date.now(),
|
||||
goal: goal.slice(0, 200),
|
||||
summary: (lastAssistant?.content || '').slice(0, 500),
|
||||
toolsUsed,
|
||||
keyFindings: [],
|
||||
tokenUsage: totalTokens,
|
||||
};
|
||||
}
|
||||
|
||||
/** R123: 格式化历史会话摘要供注入 */
|
||||
export function formatSessionSummariesForContext(summaries: SessionSummary[]): string {
|
||||
if (summaries.length === 0) return '';
|
||||
const lines = ['[历史会话参考]', ''];
|
||||
for (const s of summaries.slice(0, 3)) {
|
||||
const date = new Date(s.createdAt).toLocaleDateString();
|
||||
lines.push(`- ${date}: 目标="${s.goal.slice(0, 60)}..." | 工具=[${s.toolsUsed.join(', ')}] | 结果=${s.summary.slice(0, 100)}...`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R125: Agent 状态检查点 — 保存和恢复 Agent 状态
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface AgentCheckpoint {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
loopCount: number;
|
||||
state: string;
|
||||
messagesSnapshot: OllamaMessage[];
|
||||
toolRecordsCount: number;
|
||||
goal: string;
|
||||
}
|
||||
|
||||
const _checkpoints: AgentCheckpoint[] = [];
|
||||
const MAX_CHECKPOINTS = 5;
|
||||
|
||||
/** R125: 创建状态检查点 */
|
||||
export function createCheckpoint(
|
||||
loopCount: number,
|
||||
agentState: string,
|
||||
messages: OllamaMessage[],
|
||||
toolRecordsCount: number,
|
||||
goal: string
|
||||
): AgentCheckpoint {
|
||||
const checkpoint: AgentCheckpoint = {
|
||||
id: `cp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: Date.now(),
|
||||
loopCount,
|
||||
state: agentState,
|
||||
messagesSnapshot: messages.map(m => ({ ...m })),
|
||||
toolRecordsCount,
|
||||
goal,
|
||||
};
|
||||
|
||||
_checkpoints.push(checkpoint);
|
||||
if (_checkpoints.length > MAX_CHECKPOINTS) {
|
||||
_checkpoints.shift();
|
||||
}
|
||||
|
||||
logInfo(`R125: 检查点已创建 (loop=${loopCount}, state=${agentState})`);
|
||||
return checkpoint;
|
||||
}
|
||||
|
||||
/** R125: 获取最近的检查点 */
|
||||
export function getLatestCheckpoint(): AgentCheckpoint | null {
|
||||
return _checkpoints.length > 0 ? _checkpoints[_checkpoints.length - 1] : null;
|
||||
}
|
||||
|
||||
/** R125: 恢复到指定检查点 */
|
||||
export function restoreCheckpoint(id: string): AgentCheckpoint | null {
|
||||
const cp = _checkpoints.find(c => c.id === id);
|
||||
if (!cp) {
|
||||
logWarn(`R125: 检查点 ${id} 不存在`);
|
||||
return null;
|
||||
}
|
||||
logInfo(`R125: 恢复到检查点 ${id} (loop=${cp.loopCount})`);
|
||||
return cp;
|
||||
}
|
||||
|
||||
/** R125: 获取所有检查点 */
|
||||
export function getAllCheckpoints(): AgentCheckpoint[] {
|
||||
return [..._checkpoints];
|
||||
}
|
||||
|
||||
/** R125: 清除所有检查点 */
|
||||
export function clearCheckpoints(): void {
|
||||
_checkpoints.length = 0;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R126: 上下文预算分配 — 按消息类型分配上下文 token 预算
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface ContextBudgetAllocation {
|
||||
system: number; // 系统消息预算
|
||||
user: number; // 用户消息预算
|
||||
assistant: number; // 助手消息预算
|
||||
tool: number; // 工具结果预算
|
||||
memory: number; // 记忆注入预算
|
||||
total: number; // 总预算
|
||||
}
|
||||
|
||||
/** R126: 默认预算分配比例 */
|
||||
const DEFAULT_BUDGET_RATIOS = {
|
||||
system: 0.05, // 5%
|
||||
user: 0.15, // 15%
|
||||
assistant: 0.25, // 25%
|
||||
tool: 0.45, // 45%
|
||||
memory: 0.10, // 10%
|
||||
};
|
||||
|
||||
/** R126: 根据消息分布动态调整预算分配 */
|
||||
export function allocateContextBudget(
|
||||
messages: OllamaMessage[],
|
||||
numCtx: number
|
||||
): ContextBudgetAllocation {
|
||||
const total = numCtx;
|
||||
|
||||
// 统计各类型消息当前占比
|
||||
const counts = { system: 0, user: 0, assistant: 0, tool: 0 };
|
||||
let memorySize = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role in counts) {
|
||||
counts[msg.role as keyof typeof counts]++;
|
||||
}
|
||||
if (msg.content?.includes('[记忆注入]')) {
|
||||
memorySize += estimateTokens(msg.content);
|
||||
}
|
||||
}
|
||||
|
||||
const totalMsgs = messages.length || 1;
|
||||
|
||||
// 动态调整:如果工具结果占比过高,增加工具预算
|
||||
const toolRatio = counts.tool / totalMsgs;
|
||||
const ratios = { ...DEFAULT_BUDGET_RATIOS };
|
||||
|
||||
if (toolRatio > 0.5) {
|
||||
// 工具结果过多,从助手预算中转移一部分给工具
|
||||
const shift = Math.min(0.1, (toolRatio - 0.5) * 0.3);
|
||||
ratios.assistant -= shift;
|
||||
ratios.tool += shift;
|
||||
}
|
||||
|
||||
// 如果记忆注入很大,增加记忆预算
|
||||
if (memorySize > numCtx * 0.1) {
|
||||
const shift = Math.min(0.05, (memorySize / numCtx - 0.1) * 0.2);
|
||||
ratios.tool -= shift;
|
||||
ratios.memory += shift;
|
||||
}
|
||||
|
||||
return {
|
||||
system: Math.floor(total * ratios.system),
|
||||
user: Math.floor(total * ratios.user),
|
||||
assistant: Math.floor(total * ratios.assistant),
|
||||
tool: Math.floor(total * ratios.tool),
|
||||
memory: Math.floor(total * ratios.memory),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/** R126: 检查消息是否超出预算 */
|
||||
export function checkBudgetOverflow(
|
||||
messages: OllamaMessage[],
|
||||
budget: ContextBudgetAllocation
|
||||
): { role: string; current: number; budget: number; overflow: number }[] {
|
||||
const tokensByRole: Record<string, number> = {};
|
||||
for (const msg of messages) {
|
||||
tokensByRole[msg.role] = (tokensByRole[msg.role] || 0) + estimateTokens(msg.content || '');
|
||||
}
|
||||
|
||||
const overflows: { role: string; current: number; budget: number; overflow: number }[] = [];
|
||||
const budgetMap: Record<string, number> = {
|
||||
system: budget.system,
|
||||
user: budget.user,
|
||||
assistant: budget.assistant,
|
||||
tool: budget.tool,
|
||||
};
|
||||
|
||||
for (const [role, current] of Object.entries(tokensByRole)) {
|
||||
const bud = budgetMap[role] || Infinity;
|
||||
if (current > bud) {
|
||||
overflows.push({ role, current, budget: bud, overflow: current - bud });
|
||||
}
|
||||
}
|
||||
|
||||
return overflows;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ export interface MemoryEntry {
|
||||
content: string;
|
||||
importance: number; // 1-10
|
||||
tags: string[];
|
||||
// R106: 访问追踪 — 用于 TTL 衰减时的保留策略
|
||||
lastAccessed?: number; // 最后访问时间(毫秒时间戳)
|
||||
accessCount?: number; // 访问次数
|
||||
}
|
||||
|
||||
export interface MemorySearchResult extends MemoryEntry {
|
||||
@@ -283,8 +286,54 @@ export function serializeMemoryMd(entries: MemoryEntry[]): string {
|
||||
// 搜索
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* R101: 计算词项的 IDF(逆文档频率)权重
|
||||
* 在所有记忆条目中出现频率越低的词,IDF 越高
|
||||
*/
|
||||
function computeIDF(entries: MemoryEntry[], words: string[]): Map<string, number> {
|
||||
const idfMap = new Map<string, number>();
|
||||
const N = entries.length;
|
||||
if (N === 0) return idfMap;
|
||||
|
||||
for (const word of words) {
|
||||
let docFreq = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.content.toLowerCase().includes(word)) docFreq++;
|
||||
}
|
||||
// IDF = log(N / (df + 1)),加 1 防止除零
|
||||
const idf = Math.log((N + 1) / (docFreq + 1)) + 1;
|
||||
idfMap.set(word, idf);
|
||||
}
|
||||
return idfMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* R101: 模糊匹配 — 检查两个短字符串的编辑距离是否在阈值内
|
||||
* 仅对长度 <= 10 的短词使用,避免性能开销
|
||||
*/
|
||||
function fuzzyMatch(a: string, b: string, maxDistance: number): boolean {
|
||||
if (a === b) return true;
|
||||
if (Math.abs(a.length - b.length) > maxDistance) return false;
|
||||
// 简化编辑距离(Levenshtein)
|
||||
const matrix: number[][] = [];
|
||||
for (let i = 0; i <= a.length; i++) matrix[i] = [i];
|
||||
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] + cost,
|
||||
);
|
||||
}
|
||||
}
|
||||
return matrix[a.length][b.length] <= maxDistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键词搜索记忆
|
||||
* R101: 增强 — IDF 权重 + 模糊匹配 + 多词短语奖励
|
||||
*/
|
||||
export function searchMemory(entries: MemoryEntry[], query: string, limit = 0): MemorySearchResult[] {
|
||||
if (!query || entries.length === 0) return [];
|
||||
@@ -292,6 +341,9 @@ export function searchMemory(entries: MemoryEntry[], query: string, limit = 0):
|
||||
const queryLower = query.toLowerCase();
|
||||
const queryWords = queryLower.split(/[\s,,。!?、;:""''()\[\]{}<>`~@#$%^&*+=|\\/.]+/).filter(w => w.length > 1);
|
||||
|
||||
// R101: 预计算 IDF 权重
|
||||
const idfMap = computeIDF(entries, queryWords);
|
||||
|
||||
// rule 和 preference 类型的记忆全局生效,始终注入
|
||||
// M5: 限制全局注入数量,避免大量 rule/preference 膨胀搜索结果
|
||||
const MAX_GLOBAL_INJECT = 10;
|
||||
@@ -310,28 +362,70 @@ export function searchMemory(entries: MemoryEntry[], query: string, limit = 0):
|
||||
const contentLower = entry.content.toLowerCase();
|
||||
const tagsLower = entry.tags.map(t => t.toLowerCase());
|
||||
|
||||
// 精确匹配查询字符串
|
||||
if (contentLower.includes(queryLower)) score += 50;
|
||||
|
||||
// R101: 多词短语连续匹配奖励
|
||||
if (queryWords.length >= 2 && contentLower.includes(queryWords.join(' '))) {
|
||||
score += 25;
|
||||
}
|
||||
|
||||
for (const tag of tagsLower) {
|
||||
if (queryLower.includes(tag) || tag.includes(queryLower)) score += 30;
|
||||
for (const word of queryWords) {
|
||||
if (tag.includes(word)) score += 15;
|
||||
if (tag.includes(word)) {
|
||||
// R101: 使用 IDF 权重
|
||||
score += 15 * (idfMap.get(word) || 1);
|
||||
}
|
||||
// R101: 对短词进行模糊匹配
|
||||
if (word.length <= 10 && tag.length <= 15 && fuzzyMatch(word, tag, 1)) {
|
||||
score += 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const word of queryWords) {
|
||||
if (contentLower.includes(word)) score += 10;
|
||||
if (contentLower.includes(word)) {
|
||||
// R101: 使用 IDF 权重
|
||||
score += 10 * (idfMap.get(word) || 1);
|
||||
}
|
||||
// R101: 对短词进行模糊匹配
|
||||
if (word.length <= 10) {
|
||||
// 检查内容中是否有编辑距离为 1 的近似词
|
||||
const words = contentLower.split(/\s+/);
|
||||
for (const cw of words) {
|
||||
if (cw.length <= 12 && fuzzyMatch(word, cw, 1)) {
|
||||
score += 5;
|
||||
break; // 每个查询词只奖励一次
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
score *= (0.5 + entry.importance / 20);
|
||||
if (score > 0) scored.push({ ...entry, score });
|
||||
if (score > 0) scored.push({ ...entry, score: Math.round(score) });
|
||||
}
|
||||
|
||||
// 去重 + 排序
|
||||
const seen = new Set<string>();
|
||||
const merged: MemorySearchResult[] = [];
|
||||
const accessedIds: string[] = []; // R106: 需要记录访问的 ID
|
||||
|
||||
for (const item of [...alwaysInclude, ...scored.sort((a, b) => b.score - a.score)]) {
|
||||
if (!seen.has(item.id)) {
|
||||
seen.add(item.id);
|
||||
merged.push(item);
|
||||
// R106: 记录访问(但不持久化,只影响本次搜索后的衰减判断)
|
||||
accessedIds.push(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
// R106: 更新原始 entries 中的访问追踪(影响未来的 TTL 衰减)
|
||||
// 注意:这不会立即持久化,在 writeMemoryFile 时才生效
|
||||
for (const id of accessedIds) {
|
||||
const entry = entries.find(e => e.id === id);
|
||||
if (entry) {
|
||||
entry.lastAccessed = Date.now();
|
||||
entry.accessCount = (entry.accessCount || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +537,8 @@ export async function loadAllEntries(): Promise<MemoryEntry[]> {
|
||||
/** 搜索记忆(读取 MEMORY.md → 解析 → 搜索) */
|
||||
export async function search(query: string, limit = 0): Promise<MemorySearchResult[]> {
|
||||
try {
|
||||
// R57: 触发 TTL 衰减检查(带节流,不会每次搜索都执行)
|
||||
maybeRunTTLDecay().catch(() => {}); // 非阻塞,失败不影响搜索
|
||||
const entries = await loadAllEntries();
|
||||
return searchMemory(entries, query, limit);
|
||||
} catch (err) {
|
||||
@@ -812,6 +908,137 @@ function autoCleanEntries(entries: MemoryEntry[]): MemoryEntry[] {
|
||||
return entries.filter(e => !evictIds.has(e.id));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R57: 记忆 TTL 衰减 — 基于时间的自动重要性衰减
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** R57: 上次 TTL 衰减执行时间(避免每次搜索都触发文件写入) */
|
||||
let _lastTTLDecayTime = 0;
|
||||
const TTL_DECAY_INTERVAL = 6 * 3600 * 1000; // 每 6 小时最多执行一次衰减
|
||||
|
||||
/** R57: 从记忆 ID 中提取创建日期 */
|
||||
function extractDateFromId(id: string): number {
|
||||
const m = id.match(/mem_(\d{4})(\d{2})(\d{2})_/);
|
||||
if (m) {
|
||||
return new Date(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3])).getTime();
|
||||
}
|
||||
return Date.now(); // 无法解析时视为刚创建
|
||||
}
|
||||
|
||||
/**
|
||||
* R106: 记录条目访问 — 增强访问频率追踪用于 TTL 衰减时的保留策略
|
||||
* 在搜索匹配、检索时调用
|
||||
*/
|
||||
export function recordMemoryAccess(entryId: string, entries: MemoryEntry[]): void {
|
||||
const entry = entries.find(e => e.id === entryId);
|
||||
if (entry) {
|
||||
entry.lastAccessed = Date.now();
|
||||
entry.accessCount = (entry.accessCount || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* R57: 对记忆条目应用 TTL 衰减
|
||||
*
|
||||
* 衰减规则:
|
||||
* - rule 类型:完全免疫衰减(永久规则)
|
||||
* - preference 类型:轻微衰减(用户偏好通常长期有效)
|
||||
* - fact 类型:按年龄衰减,低重要性的旧事实优先遗忘
|
||||
* - importance 已经 ≤ 2 的 fact 且超过 60 天:自动移除
|
||||
* - R106: 访问频率作为保留因素(最近访问或高频访问的条目优先保留)
|
||||
*
|
||||
* @param entries 当前所有记忆条目
|
||||
* @returns { decayed: MemoryEntry[], removed: number, changed: boolean }
|
||||
*/
|
||||
export function applyTTLDecay(entries: MemoryEntry[]): { decayed: MemoryEntry[]; removed: number; changed: boolean } {
|
||||
const now = Date.now();
|
||||
const ONE_DAY = 24 * 3600 * 1000;
|
||||
const removed: string[] = [];
|
||||
let changed = false;
|
||||
|
||||
const decayed = entries.filter(entry => {
|
||||
const createdAt = extractDateFromId(entry.id);
|
||||
const ageDays = (now - createdAt) / ONE_DAY;
|
||||
const lastAccessed = entry.lastAccessed || createdAt;
|
||||
const accessCount = entry.accessCount || 0;
|
||||
const daysSinceAccess = (now - lastAccessed) / ONE_DAY;
|
||||
|
||||
// R106: 访问频率保护 — 最近 30 天内有访问或访问次数 >= 3 的条目优先保留
|
||||
const accessProtected = daysSinceAccess < 30 || accessCount >= 3;
|
||||
|
||||
// rule 类型永久保留
|
||||
if (entry.type === 'rule') return true;
|
||||
|
||||
// preference 类型:90 天以上且 importance ≤ 3 才移除(访问频率可豁免)
|
||||
if (entry.type === 'preference') {
|
||||
if (ageDays > 90 && entry.importance <= 3 && !accessProtected) {
|
||||
removed.push(entry.id);
|
||||
changed = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// fact 类型:按年龄和重要性衰减
|
||||
// 衰减公式:每 30 天 importance - 1(仅对 importance < 8 的条目)
|
||||
if (entry.type === 'fact') {
|
||||
// R106: 访问频率保护 — 最近访问过的不易删除
|
||||
if (accessProtected && ageDays < 120) {
|
||||
// 120 天内访问过的 fact 豁免删除
|
||||
return true;
|
||||
}
|
||||
|
||||
// 超过 60 天且 importance ≤ 2 → 自动移除
|
||||
if (ageDays > 60 && entry.importance <= 2 && !accessProtected) {
|
||||
removed.push(entry.id);
|
||||
changed = true;
|
||||
return false;
|
||||
}
|
||||
// 超过 90 天且 importance ≤ 4 → 自动移除
|
||||
if (ageDays > 90 && entry.importance <= 4 && !accessProtected) {
|
||||
removed.push(entry.id);
|
||||
changed = true;
|
||||
return false;
|
||||
}
|
||||
// 高重要性的 fact 永久保留
|
||||
if (entry.importance >= 8) return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (removed.length > 0) {
|
||||
logMemory('TTL 衰减', `自动移除 ${removed.length} 条过期记忆(fact/preference 类型,低重要性+超龄)`);
|
||||
}
|
||||
|
||||
return { decayed, removed: removed.length, changed };
|
||||
}
|
||||
|
||||
/**
|
||||
* R57: 执行 TTL 衰减并持久化(带节流保护)
|
||||
* 在搜索记忆时调用,如果距离上次衰减超过 TTL_DECAY_INTERVAL 则执行
|
||||
*/
|
||||
async function maybeRunTTLDecay(): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (now - _lastTTLDecayTime < TTL_DECAY_INTERVAL) return;
|
||||
_lastTTLDecayTime = now;
|
||||
|
||||
try {
|
||||
const entries = await loadAllEntries();
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const { decayed, removed, changed } = applyTTLDecay(entries);
|
||||
if (changed && removed > 0) {
|
||||
const fileContent = decayed.length > 0 ? serializeMemoryMd(decayed) : '';
|
||||
await writeMemoryFile(fileContent);
|
||||
logMemory('TTL 衰减', `已持久化: 移除 ${removed} 条,剩余 ${decayed.length} 条`);
|
||||
}
|
||||
} catch (err) {
|
||||
logWarn('TTL 衰减执行失败', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查记忆系统是否可用(桌面环境且工作空间已设置)
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,8 @@ import { OllamaAPI } from '../api/ollama.js';
|
||||
import { TOOL_DEFINITIONS } from './tool-registry.js';
|
||||
import { getEnabledToolDefinitions } from './tool-registry.js';
|
||||
import { logInfo, logWarn, logError } from './log-service.js';
|
||||
import { validatePathSandbox, checkRateLimit, isToolCircuitBroken, recordToolFailure, recordToolSuccess, sanitizeToolArgs, checkCommandSafety } from './agent-safety.js';
|
||||
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
||||
import type { ToolResult, ToolCall, ToolDefinition } from '../types.js';
|
||||
|
||||
const SUB_AGENT_MAX_LOOPS = 10; // 子代理最多 10 轮
|
||||
@@ -169,9 +171,82 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
|
||||
// 工具执行前再次检查中止信号
|
||||
if (subAgentAC.signal.aborted) break;
|
||||
|
||||
// R89: 子 Agent 熔断器检查 — 连续失败后自动禁用工具
|
||||
const circuitBreaker = isToolCircuitBroken(tc.name);
|
||||
if (circuitBreaker.broken) {
|
||||
const waitSec = Math.ceil(circuitBreaker.remainingMs / 1000);
|
||||
logWarn(`R89: 子 Agent 熔断器拦截: ${tc.name},冷却中(还需 ${waitSec}s)`);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: `工具「${tc.name}」因连续失败已被暂时禁用,请等待 ${waitSec} 秒后重试。` })}\n<<<TOOL_RESULT_END>>>`,
|
||||
tool_name: tc.name
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// R109: 子 Agent 参数消毒
|
||||
tc.arguments = sanitizeToolArgs(tc.name, tc.arguments);
|
||||
|
||||
// R113: 子 Agent 命令安全检查
|
||||
if (tc.name === 'run_command') {
|
||||
const cmdStr = String(tc.arguments?.command || '');
|
||||
if (cmdStr) {
|
||||
const cmdSafety = checkCommandSafety(cmdStr);
|
||||
if (cmdSafety.riskLevel === 'forbidden') {
|
||||
logWarn(`R113: 子 Agent 命令安全拦截: ${cmdSafety.reason}`);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: cmdSafety.reason || '命令被安全规则拦截' })}\n<<<TOOL_RESULT_END>>>`,
|
||||
tool_name: tc.name
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R82: 子 Agent 速率限制 — 防止子代理快速连续调用同一工具
|
||||
const rateLimit = checkRateLimit(tc.name);
|
||||
if (!rateLimit.allowed) {
|
||||
const waitSec = Math.ceil(rateLimit.retryAfterMs / 1000);
|
||||
logWarn(`R82: 子 Agent 速率限制: ${tc.name},等待 ${waitSec}s`);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: `调用频率过高,等待 ${waitSec}s` })}\n<<<TOOL_RESULT_END>>>`,
|
||||
tool_name: tc.name
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// R81: 子 Agent 路径沙箱 — 确保文件操作不超出工作空间
|
||||
const SUB_FILE_TOOLS = new Set(['read_file', 'list_directory', 'search_files', 'web_fetch']);
|
||||
if (SUB_FILE_TOOLS.has(tc.name)) {
|
||||
const wsDir = getWorkspaceDirPath();
|
||||
if (wsDir) {
|
||||
const pathArg = String(tc.arguments?.path || '');
|
||||
if (pathArg) {
|
||||
const sandbox = validatePathSandbox(pathArg, wsDir);
|
||||
if (!sandbox.valid) {
|
||||
logWarn(`R81: 子 Agent 路径沙箱拦截: ${tc.name}(${pathArg}) — ${sandbox.reason}`);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: sandbox.reason || '路径不在工作空间范围内' })}\n<<<TOOL_RESULT_END>>>`,
|
||||
tool_name: tc.name
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { executeTool } = await import('./tool-registry.js');
|
||||
const result = await executeTool(tc.name, tc.arguments);
|
||||
// R89: 记录工具成功/失败到熔断器
|
||||
if (result.success) {
|
||||
recordToolSuccess(tc.name);
|
||||
} else {
|
||||
recordToolFailure(tc.name);
|
||||
}
|
||||
const resultStr = formatResult(tc.name, result);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
|
||||
@@ -754,6 +754,91 @@ export function getEnabledToolDefinitions(): ToolDefinition[] {
|
||||
return [...builtIn, ..._mcpToolCache];
|
||||
}
|
||||
|
||||
// R55: 语义工具检索 — 根据用户查询过滤相关工具,减少上下文占用
|
||||
|
||||
/** 核心工具集 — 始终包含的关键工具 */
|
||||
const CORE_TOOLS = new Set([
|
||||
'read_file', 'write_file', 'list_directory', 'search_files', 'edit_file',
|
||||
'run_command', 'web_search', 'web_fetch', 'memory', 'git',
|
||||
]);
|
||||
|
||||
/** 工具关键词映射 — 用于语义匹配 */
|
||||
const TOOL_KEYWORDS: Record<string, string[]> = {
|
||||
read_file: ['read', 'file', '读取', '文件', '看', '查看', '内容'],
|
||||
write_file: ['write', 'file', '写入', '保存', '创建文件', '输出'],
|
||||
list_directory: ['list', 'directory', '目录', '文件夹', '列'],
|
||||
search_files: ['search', 'find', '搜索', '查找', 'grep', 'find'],
|
||||
create_directory: ['create', 'directory', 'mkdir', '创建目录', '新建'],
|
||||
delete_file: ['delete', 'remove', '删除', 'rm', '移除'],
|
||||
run_command: ['run', 'command', 'shell', '执行', '命令', '终端'],
|
||||
move_file: ['move', 'rename', '移动', '重命名', 'mv'],
|
||||
copy_file: ['copy', '复制', 'cp'],
|
||||
web_fetch: ['fetch', 'url', '网页', '抓取', '获取'],
|
||||
web_search: ['search', 'web', '搜索', '联网', '查', 'google', 'bing'],
|
||||
edit_file: ['edit', 'replace', '编辑', '替换', '修改'],
|
||||
get_file_info: ['info', 'stat', '信息', '属性', '详情'],
|
||||
tree: ['tree', '结构', '树', '目录结构'],
|
||||
download_file: ['download', '下载'],
|
||||
diff_files: ['diff', 'compare', '对比', '差异'],
|
||||
replace_in_files: ['replace', 'batch', '批量替换'],
|
||||
read_multiple_files: ['read', 'multiple', '批量读取'],
|
||||
git: ['git', 'commit', 'push', 'pull', 'branch', '仓库'],
|
||||
compress: ['compress', 'zip', 'tar', '压缩', '解压', 'extract'],
|
||||
memory: ['memory', '记忆', 'remember', 'save', 'rule', '规则'],
|
||||
session_list: ['session', 'history', '会话', '历史'],
|
||||
session_read: ['session', 'read', '读取会话'],
|
||||
spawn_task: ['sub', 'agent', 'delegate', '子代理', '委派'],
|
||||
plan_track: ['plan', 'track', '计划', '进度'],
|
||||
browser_open: ['browser', 'open', '浏览器', '打开网页'],
|
||||
browser_screenshot: ['screenshot', '截图', '屏幕'],
|
||||
browser_evaluate: ['evaluate', 'javascript', 'js', '执行JS'],
|
||||
browser_extract: ['extract', '提取', '内容'],
|
||||
browser_click: ['click', '点击'],
|
||||
browser_type: ['type', 'input', '输入'],
|
||||
browser_scroll: ['scroll', '滚动'],
|
||||
browser_wait: ['wait', '等待'],
|
||||
browser_close: ['close', '关闭浏览器'],
|
||||
datetime: ['time', 'date', '时间', '日期'],
|
||||
calculator: ['calculate', 'math', '计算', '算'],
|
||||
random: ['random', '随机'],
|
||||
uuid: ['uuid', 'guid', '唯一'],
|
||||
json_format: ['json', 'format', '格式化'],
|
||||
hash: ['hash', 'sha', 'md5', '哈希'],
|
||||
};
|
||||
|
||||
/** R55: 根据用户查询语义过滤工具定义,减少 token 占用 */
|
||||
export function getRelevantToolDefinitions(userQuery: string): ToolDefinition[] {
|
||||
const allTools = getEnabledToolDefinitions();
|
||||
// 查询为空或很短时返回全部
|
||||
if (!userQuery || userQuery.length < 5) return allTools;
|
||||
|
||||
const queryLower = userQuery.toLowerCase();
|
||||
const relevantTools = new Set<string>();
|
||||
|
||||
// 核心工具始终包含
|
||||
for (const t of CORE_TOOLS) relevantTools.add(t);
|
||||
|
||||
// 语义匹配
|
||||
for (const [toolName, keywords] of Object.entries(TOOL_KEYWORDS)) {
|
||||
if (relevantTools.has(toolName)) continue;
|
||||
for (const kw of keywords) {
|
||||
if (queryLower.includes(kw.toLowerCase())) {
|
||||
relevantTools.add(toolName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果匹配到的工具太少,返回全部(避免遗漏)
|
||||
if (relevantTools.size < 8) return allTools;
|
||||
|
||||
const filtered = allTools.filter(t => relevantTools.has(t.function.name));
|
||||
// 确保不过度过滤 — 至少保留 60% 的工具
|
||||
if (filtered.length < allTools.length * 0.6) return allTools;
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/** MCP 工具缓存 */
|
||||
let _mcpToolCache: ToolDefinition[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user