gns3-server/docs/gns3-copilot/context-window-management.md
YueGuobin 8dc8facb13 docs: simplify context window management documentation
Removed extensive implementation details and configuration examples from the context window management documentation. The document now focuses on core permissions and prohibitions for tool usage, providing a clearer and more concise reference for allowed and forbidden actions. This streamlines the documentation to essential guidelines only.
2026-03-05 14:05:58 +08:00

327 lines
12 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LLM 上下文窗口管理实现文档
## 概述
本文档说明 GNS3 Copilot 的上下文窗口管理实现机制包括消息裁剪、Token 计数和配置验证。
## 实现架构
### 1. 核心模块
**文件位置**: `gns3server/agent/gns3_copilot/agent/context_manager.py`
#### Token 计数策略
系统使用 **tiktoken** 进行 Token 计数context_manager.py:60
```python
_tiktoken_encoding = tiktoken.get_encoding("cl100k_base")
```
**必需依赖**
```bash
pip install tiktoken>=0.8.0
```
如果未安装 tiktoken系统将在启动时抛出 `ModuleNotFoundError`
#### 关键函数
**`count_tokens(text: str) -> int`** (context_manager.py:84-100)
- 使用 tiktoken 准确计数文本的 token 数
- 使用 `cl100k_base` 编码
- 返回精确的 token 数
**`estimate_tool_tokens(tools: list) -> int`** (context_manager.py:103-169)
- 序列化工具 schema 为 JSON
- 使用 tiktoken 计数工具定义的 token 消耗
- 支持 Pydantic v1/v2 兼容性
- 失败时使用 1000 tokens 的回退值
**`create_pre_model_hook(...)`** (context_manager.py:195-402)
- 创建预处理函数pre_model_hook
- 在每次 LLM 调用前自动执行:
1. 注入 topology 信息到 system prompt
2. 估算工具定义的 token 消耗
3. 裁剪消息历史以适应上下文限制
- 返回一个可调用的函数,用于准备消息
### 2. 裁剪逻辑详解
#### 2.1 Token 预算分配
当调用 LLM 时,发送的内容包含两部分:
```
发送给 LLM 的完整请求:
┌─────────────────────────────────────────────────────────────┐
│ 1. Messages (我们管理的) │
│ ├─ SystemMessage: system prompt + topology (模板注入) │
│ └─ HumanMessage/AIMessage: 用户消息 / 历史消息 │
├─────────────────────────────────────────────────────────────┤
│ 2. Tool Definitions (LangChain 自动添加,不在消息中) │
│ ├─ Tool 1 schema (name, description, parameters) │
│ ├─ Tool 2 schema │
│ └─ ... (约 500-1500 tokens per tool) │
└─────────────────────────────────────────────────────────────┘
```
**System Message 结构**
- 使用模板变量 `{{topology_info}}` 动态注入 topology
- System prompt 包含占位符:`"### CURRENT TOPOLOGY\n{{topology_info}}"`
- 如果有 topology替换为实际内容
- 如果没有 topology替换为 `"(No topology information available)"`
#### 2.2 裁剪流程
```
第一步:计算输入预算
┌─────────────────────────────────────────────────────────────┐
│ context_limit: 128,000 tokens (128K) │
│ strategy: balanced (75%) │
│ │
│ 输入预算 = 128 × 1000 × 0.75 = 96,000 tokens │
└─────────────────────────────────────────────────────────────┘
第二步:减去工具定义
┌─────────────────────────────────────────────────────────────┐
│ 输入预算: 96,000 tokens │
│ 工具定义: 1,725 tokens │
│ │
│ 可用于消息 = 96,000 - 1,725 = 94,275 tokens │
└─────────────────────────────────────────────────────────────┘
第三步trim_messages 处理
┌─────────────────────────────────────────────────────────────┐
│ 调用 LangChain 的 trim_messages: │
│ - max_tokens = 94,275 (包含 system message) │
│ - strategy = "last" (保留最新消息) │
│ - token_counter = tiktoken 计数函数 │
│ - include_system = True (始终保留 system) │
│ │
│ trim_messages 会: │
│ 1. 保留 SystemMessage (system + topology) │
│ 2. 从最新消息开始,保留尽可能多的历史 │
│ 3. 超出限制时,丢弃最旧的消息 │
└─────────────────────────────────────────────────────────────┘
```
#### 2.3 裁剪优先级
系统按以下优先级保留内容:
| 优先级 | 内容 | 说明 |
|--------|------|------|
| 1⃣ | System Message (system prompt + topology) | 永远保留 |
| 2⃣ | 最新用户消息 | 至少保留最后1条 |
| 3⃣ | 旧对话历史 | 按时间顺序丢弃 |
**注意**System prompt 和 topology info 通过模板变量合并为一个 SystemMessage无法单独分离。
#### 2.4 边界情况处理
| 情况 | 处理方式 |
|------|----------|
| System (包含 topology) > 预算 | 保留完整的 System Message无法分离 system 和 topology |
| Tools > 预算 | ERROR 日志,建议增加 context_limit 或减少工具数量 |
| 历史全被裁剪 | 保留最后1条用户消息 |
**重要提示**
- 当 system + topology 超出可用预算时,**两者都会被保留**
- 无法只丢弃 topology 而保留 system prompt因为已合并
### 3. 集成到 GNS3 Copilot
**文件位置**: `gns3server/agent/gns3_copilot/agent/gns3_copilot.py`
#### 实现方式
**关键点**:系统使用**自定义 StateGraph**,不是 LangGraph 的预构建 agent。
因此,`pre_model_hook` 不能通过 `model.invoke(config={"configurable": {"pre_model_hook": ...}})` 传递。
**正确的使用方式****直接调用** `pre_hook` 函数准备消息。
```python
def llm_call(state: dict, config: RunnableConfig | None = None):
"""LLM decides whether to call a tool or not."""
# 1. 获取 topology 信息
project_id = config["configurable"].get("project_id")
topology_info = None
if project_id:
topology_tool = GNS3TopologyTool()
topology = topology_tool._run(project_id=project_id)
if topology and "error" not in topology:
topology_info = topology
# 2. 创建 pre_model_hook
system_prompt = load_system_prompt()
pre_hook = create_pre_model_hook(
system_prompt=system_prompt,
get_topology_func=lambda s: s.get("topology_info"),
get_llm_config_func=get_current_llm_config,
get_tools_func=lambda: tools,
)
# 3. 创建 model with tools
model_with_tools = create_base_model_with_tools(tools, llm_config=llm_config)
# 4. ⭐ 关键:直接调用 pre_hook 准备消息
logger.info("Calling pre_hook to prepare %d messages", len(messages))
prepared_state = pre_hook({"messages": messages, "topology_info": topology_info})
prepared_messages = prepared_state["messages"]
# 5. 使用准备好的消息调用 LLM
response = model_with_tools.invoke(prepared_messages)
return {"messages": [response], ...}
```
#### 为什么不通过 config 传递?
LangGraph 的 `pre_model_hook` 参数仅适用于**预构建的 agent**,不适用于自定义 StateGraph。
| Agent 类型 | pre_model_hook 支持方式 |
|------------|------------------------|
| `create_react_agent` | ✅ 通过 `pre_model_hook` 参数 |
| `chat_agent_executor` | ✅ 通过 `pre_model_hook` 参数 |
| **自定义 StateGraph** | ❌ **不支持**,需要直接调用 |
我们的实现使用的是自定义 StateGraph`agent_builder = StateGraph(MessagesState)`),所以必须直接调用 `pre_hook`
### 4. 执行流程
```
用户发送消息
llm_call 节点被调用
获取 project_id (从 config["configurable"])
调用 GNS3TopologyTool._run(project_id) 获取 topology
存储 topology_info 到 state
创建 pre_model_hook (通过 create_pre_model_hook())
【关键】直接调用 pre_hook({"messages": messages, "topology_info": topology_info})
├─ 1. 注入 topology 到 system prompt
├─ 2. 估算工具定义 tokens
├─ 3. 调用 trim_messages() 裁剪消息
└─ 4. 返回准备好的消息列表
使用准备好的消息调用 model.invoke()
返回 LLM 响应
```
---
## 策略实现
### Context Strategy Ratios
**定义**context_manager.py:68-72
```python
CONTEXT_STRATEGY_RATIOS = {
"conservative": 0.60,
"balanced": 0.75,
"aggressive": 0.85,
}
```
**默认值**context_manager.py:74
```python
DEFAULT_CONTEXT_STRATEGY = "balanced"
```
### 策略对比
| 策略 | 输入比例 | 输出预留 | 计算公式 |
|------|---------|---------|---------|
| Conservative | 60% | 40% | `context_limit × 1000 × 0.60` |
| Balanced | 75% | 25% | `context_limit × 1000 × 0.75` |
| Aggressive | 85% | 15% | `context_limit × 1000 × 0.85` |
---
## 日志输出
### 正常情况topology 成功注入)
```
INFO: Calling pre_hook to prepare 1 messages
INFO: ✓ Topology injected: 7722 chars, nodes: ['netshoot-1', 'R1', 'R2', 'IOU-L3-1', 'IOU-L3-2']
INFO: Context ready: 2 msgs, ~3815 tokens + 1725 tools = 5540 / 128K (4.3%), strategy=conservative
INFO: Messages prepared: 1 → 2
INFO: LLM call completed: tool_calls=0
```
### 发生裁剪时
```
INFO: Calling pre_hook to prepare 50 messages
INFO: ✓ Topology injected: 8500 chars, nodes: ['R1', 'R2', ...]
INFO: Messages trimmed: 50 → 25 msgs. Total: ~82000 tokens + 1725 tools = 83725 / 128K (65.4%), strategy=balanced
INFO: Messages prepared: 50 → 25
```
### topology 为 None 时
```
INFO: Calling pre_hook to prepare 1 messages
WARNING: ✗ Topology data is None, injecting placeholder
INFO: Context ready: 2 msgs, ~800 tokens + 1725 tools = 2525 / 128K (2.0%), strategy=balanced
```
---
## 错误处理
### tiktoken 未安装
如果 tiktoken 未安装,系统将在启动时抛出错误:
```python
ModuleNotFoundError: No module named 'tiktoken'
```
**解决方法**
```bash
pip install tiktoken>=0.8.0
```
### context_limit 缺失或无效
如果 LLM 配置中没有 `context_limit` 或值无效context_manager.py:285-295
```python
if "context_limit" not in llm_config:
raise ValueError("context_limit is required in LLM config")
limit = llm_config["context_limit"]
if not isinstance(limit, int) or limit <= 0:
raise ValueError(f"Invalid context_limit: {limit}")
```
### 裁剪失败
```python
try:
trimmed = trim_messages(...)
except Exception as e:
logger.error("Failed to trim messages: %s", e)
logger.warning("Returning original messages due to trimming error")
return {"messages": messages_with_system}
```
---
## 相关源文件
- `gns3server/agent/gns3_copilot/agent/context_manager.py` - 上下文管理核心逻辑
- `gns3server/agent/gns3_copilot/agent/gns3_copilot.py` - LLM 调用节点StateGraph
- `gns3server/agent/gns3_copilot/agent/model_factory.py` - 模型创建和工具绑定