Agent 上下文包生成器:文件选择、规则注入与 Token 裁剪
Agent 的输出质量取决于输入质量——给它精确的上下文,它就能写出精准的代码;给它模糊的上下文,它只能猜。本文设计一套上下文包生成器,从仓库文件、Issue 描述、PR 变更、历史会话中自动筛选、评分、裁剪,生成 Token 最优的上下文包。
目录
一、上下文包是什么
Agent 执行任务时,模型需要"看到"足够的代码和规则才能给出正确答案。但"看到"不是越多越好——模型有上下文窗口限制,塞入太多无关内容不仅浪费 Token,还会稀释关键信息的注意力权重。
上下文包(Context Package)是 Agent 执行任务前,由系统自动组装的一份最小充分上下文,包含:
- 必需文件:任务直接涉及的源代码文件
- 相关文件:与任务间接相关的依赖文件、接口定义、测试文件
- 项目规则:CLAUDE.md、编码规范、架构文档
- 历史上下文:关联的 Issue 描述、PR 评论、之前的 Agent 会话摘要
1.1 没有上下文包的 Agent 是怎么工作的
任务:修复 Issue #456 "用户登录时偶发 500 错误"
Agent 拿到的信息:
- Issue 标题和描述(200 tokens)
- 整个仓库文件列表(Agent 自己决定读哪些)
Agent 的行为:
1. 读 src/auth/login.py → 2000 tokens
2. 读 src/auth/session.py → 1500 tokens
3. 读 src/auth/middleware.py → 800 tokens
4. 读 src/db/connection.py → 1200 tokens(因为猜测是 DB 问题)
5. 读 src/db/models/user.py → 900 tokens
6. 读 config.yaml → 500 tokens
7. 读 README.md → 300 tokens
8. 读 src/auth/__init__.py → 200 tokens
9. 读 src/utils/logger.py → 600 tokens
10. 读 src/api/routes/auth.py → 1100 tokens
...(持续 20+ 次文件读取)
总计消耗:~15,000 tokens(仅文件读取),Agent 才开始动手修复问题是:这些文件中可能只有 3-4 个真正与 Bug 相关,其余的都是 Agent 在"试探"。每次试探都消耗 Token 和时间。
1.2 上下文包带来的收益
| 维度 | 无上下文包 | 有上下文包 |
|---|---|---|
| Token 消耗 | 15,000+(试探式读取) | 4,000-6,000(精确投喂) |
| 文件读取次数 | 15-30 次 | 0 次(文件已在包中) |
| 首次命中率 | ~30% | ~75% |
| 任务完成时间 | 5-10 轮 | 2-4 轮 |
二、上下文包生成流程
上下文包的生成分为四个阶段:收集 → 评分 → 过滤 → 裁剪。
Issue #456
PR #789
Agent 历史会话
│
▼
┌───────────────────────────┐
│ Phase 1: 候选文件收集 │
│ │
│ • Issue 关键词提取 │
│ • Git blame 关联文件 │
│ • 导入依赖分析 │
│ • 最近变更文件 │
└───────────┬───────────────┘
│ ~50 个候选文件
▼
┌───────────────────────────┐
│ Phase 2: 相关性评分 │
│ │
│ • 关键词匹配度 │
│ • 导入链路距离 │
│ • 变更频率权重 │
│ • Issue 提及权重 │
└───────────┬───────────────┘
│ 按分数排序
▼
┌───────────────────────────┐
│ Phase 3: 敏感信息过滤 │
│ │
│ • 密钥/凭证文件排除 │
│ • 生产配置排除 │
│ • 客户数据排除 │
└───────────┬───────────────┘
│ 过滤后
▼
┌───────────────────────────┐
│ Phase 4: Token 预算裁剪 │
│ │
│ • 按优先级分配 Token │
│ • 高优先文件完整保留 │
│ • 低优先文件截断/摘要 │
│ • 规则文件按层级注入 │
└───────────┬───────────────┘
│
▼
上下文包 (Context Package)
~4000-6000 tokens三、文件选择与评分算法
3.1 上下文包配置
每个任务类型可以定义不同的上下文包模板:
# context-packages.yaml
packages:
bugfix:
required_files:
# 这些文件无论如何都要包含
- pattern: "CLAUDE.md"
max_lines: 200
- pattern: ".claude/rules/*.md"
max_lines: 100
scoring:
# 文件评分权重
keyword_match: 0.35 # 文件名/内容与 Issue 关键词的匹配度
import_distance: 0.25 # 与已知相关文件的导入链路距离
change_frequency: 0.15 # 近 30 天的变更频率
issue_reference: 0.15 # Issue/PR 中直接提及的文件
test_proximity: 0.10 # 是否有对应的测试文件
budget:
total_tokens: 6000
files_allocation: 0.60 # 文件内容占 60%
rules_allocation: 0.20 # 规则占 20%
history_allocation: 0.15 # 历史上下文占 15%
reserve: 0.05 # 预留 5%
max_files: 12
min_files: 3
feature:
required_files:
- pattern: "CLAUDE.md"
max_lines: 200
- pattern: "docs/architecture.md"
max_lines: 150
scoring:
keyword_match: 0.30
import_distance: 0.30
change_frequency: 0.10
issue_reference: 0.20
test_proximity: 0.10
budget:
total_tokens: 8000
files_allocation: 0.55
rules_allocation: 0.20
history_allocation: 0.20
reserve: 0.05
max_files: 20
min_files: 53.2 文件评分函数
import re
import os
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class FileCandidate:
"""候选文件"""
path: str
score: float = 0.0
reasons: list[str] = field(default_factory=list)
token_estimate: int = 0
class ContextScorer:
"""文件相关性评分器"""
def __init__(self, repo_root: str, config: dict):
self.repo_root = Path(repo_root)
self.weights = config.get("scoring", {})
def score_files(
self,
candidates: list[str],
keywords: list[str],
related_files: list[str],
recent_changes: dict[str, int],
issue_mentions: list[str]
) -> list[FileCandidate]:
"""对所有候选文件评分并排序"""
results = []
for file_path in candidates:
candidate = FileCandidate(path=file_path)
full_path = self.repo_root / file_path
if not full_path.exists():
continue
# 估算 Token 数(粗略:1 token ≈ 4 chars / 1.3 中文字符)
content = full_path.read_text(encoding="utf-8", errors="ignore")
candidate.token_estimate = self._estimate_tokens(content)
# 1. 关键词匹配
kw_score = self._keyword_score(file_path, content, keywords)
candidate.score += kw_score * self.weights.get("keyword_match", 0.35)
if kw_score > 0.5:
candidate.reasons.append(f"关键词匹配度: {kw_score:.2f}")
# 2. 导入链路距离
import_score = self._import_distance(file_path, related_files)
candidate.score += import_score * self.weights.get("import_distance", 0.25)
if import_score > 0:
candidate.reasons.append(f"导入距离: {import_score:.2f}")
# 3. 变更频率
changes = recent_changes.get(file_path, 0)
freq_score = min(changes / 10.0, 1.0) # 10 次变更 = 满分
candidate.score += freq_score * self.weights.get("change_frequency", 0.15)
# 4. Issue 直接提及
mention_score = 1.0 if file_path in issue_mentions else 0.0
candidate.score += mention_score * self.weights.get("issue_reference", 0.15)
if mention_score > 0:
candidate.reasons.append("Issue 直接提及")
# 5. 测试文件邻近
test_score = self._test_proximity(file_path, candidates)
candidate.score += test_score * self.weights.get("test_proximity", 0.10)
results.append(candidate)
# 按分数降序
results.sort(key=lambda c: c.score, reverse=True)
return results
def _keyword_score(self, path: str, content: str, keywords: list[str]) -> float:
"""关键词匹配度:文件名匹配权重高,内容匹配权重低"""
filename = os.path.basename(path).lower()
path_lower = path.lower()
score = 0.0
for kw in keywords:
kw_lower = kw.lower()
if kw_lower in filename:
score += 0.8 # 文件名匹配,高分
elif kw_lower in path_lower:
score += 0.4 # 路径匹配,中分
elif kw_lower in content.lower():
# 内容匹配,按出现次数计分
count = content.lower().count(kw_lower)
score += min(count * 0.05, 0.3)
return min(score, 1.0)
def _import_distance(self, path: str, related_files: list[str]) -> float:
"""导入链路距离:直接导入 = 1.0,间接导入 = 0.5,无关联 = 0"""
if not related_files:
return 0.0
# 简化实现:检查文件名是否被 related_files 导入
module_name = Path(path).stem
score = 0.0
for related in related_files:
try:
content = (self.repo_root / related).read_text(errors="ignore")
if f"import {module_name}" in content or f"from {module_name}" in content:
score = max(score, 1.0)
elif module_name in content:
score = max(score, 0.5)
except (OSError, UnicodeDecodeError):
continue
return score
def _test_proximity(self, path: str, all_candidates: list[str]) -> float:
"""测试文件邻近度:如果有对应的测试文件在候选列表中"""
stem = Path(path).stem
test_patterns = [
f"test_{stem}.py", f"{stem}_test.py",
f"test_{stem}.js", f"{stem}.test.js", f"{stem}.spec.js",
f"test_{stem}.ts", f"{stem}.test.ts", f"{stem}.spec.ts",
]
for pattern in test_patterns:
if any(pattern in c for c in all_candidates):
return 1.0
return 0.0
def _estimate_tokens(self, content: str) -> int:
"""粗略估算 Token 数"""
# 英文约 1 token / 4 chars,中文约 1 token / 1.5 chars
cjk_count = sum(1 for c in content if '\u4e00' <= c <= '\u9fff')
ascii_count = len(content) - cjk_count
return int(ascii_count / 4 + cjk_count / 1.5)四、规则注入与敏感信息过滤
4.1 规则文件分层注入
项目规则按层级注入,高优先级规则永远包含,低优先级规则在 Token 预算充足时才包含:
@dataclass
class RuleFile:
path: str
priority: int # 1 = 最高,必须包含
max_lines: int
content: str = ""
class RuleInjector:
"""规则文件分层注入"""
RULE_LAYERS = [
# (目录模式, 优先级, 最大行数)
("CLAUDE.md", 1, 200), # 项目根规则
(".claude/rules/core/*.md", 2, 100), # 核心规则
(".claude/rules/style/*.md", 3, 80), # 风格规则
(".claude/rules/testing/*.md", 4, 80), # 测试规则
(".claude/rules/security/*.md", 5, 100), # 安全规则
("docs/architecture.md", 6, 150), # 架构文档
("CONTRIBUTING.md", 7, 100), # 贡献指南
]
def collect_rules(self, repo_root: Path) -> list[RuleFile]:
"""收集所有规则文件"""
rules = []
for pattern, priority, max_lines in self.RULE_LAYERS:
if "*" in pattern:
for f in repo_root.glob(pattern):
rules.append(RuleFile(str(f.relative_to(repo_root)), priority, max_lines))
else:
full = repo_root / pattern
if full.exists():
rules.append(RuleFile(pattern, priority, max_lines))
return rules
def inject(self, rules: list[RuleFile], token_budget: int) -> list[RuleFile]:
"""在 Token 预算内注入尽可能多的规则"""
result = []
used_tokens = 0
# 按优先级排序
rules.sort(key=lambda r: r.priority)
for rule in rules:
full_path = Path(rule.path)
if not full_path.exists():
continue
content = full_path.read_text(encoding="utf-8", errors="ignore")
lines = content.splitlines()
# 截断到 max_lines
if len(lines) > rule.max_lines:
content = "\n".join(lines[:rule.max_lines])
content += f"\n... (截断,共 {len(lines)} 行)"
tokens = estimate_tokens(content)
if used_tokens + tokens <= token_budget:
rule.content = content
result.append(rule)
used_tokens += tokens
elif rule.priority <= 2:
# 优先级 1-2 的规则强制包含,即使超出预算
rule.content = content
result.append(rule)
used_tokens += tokens
return result4.2 敏感信息过滤
上下文包中绝对不能包含敏感信息。过滤器在文件收集阶段就会排除:
import re
SENSITIVE_PATTERNS = [
# 密钥和凭证
re.compile(r'(?i)(password|secret|api[_-]?key|token|credential)\s*[=:]\s*["\'][^"\']+["\']'),
re.compile(r'(?i)AWS_(SECRET_)?ACCESS_KEY\s*='),
re.compile(r'(?i)(GITHUB_TOKEN|NPM_TOKEN|DATABASE_URL)\s*='),
# 私钥
re.compile(r'-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----'),
# 数据库连接串(含密码)
re.compile(r'(postgres|mysql|mongodb)://[^:]+:[^@]+@'),
]
SENSITIVE_FILES = [
"*.env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx",
"**/secrets/**", "**/credentials/**", "**/.aws/**",
"id_rsa", "id_ed25519", "*.keystore",
]
class SensitiveFilter:
"""敏感信息过滤器"""
def is_sensitive_file(self, path: str) -> bool:
"""文件路径是否匹配敏感模式"""
from fnmatch import fnmatch
return any(fnmatch(path, pat) for pat in SENSITIVE_FILES)
def contains_sensitive_data(self, content: str) -> tuple[bool, list[str]]:
"""文件内容是否包含敏感数据,返回 (是否敏感, 匹配项列表)"""
matches = []
for pattern in SENSITIVE_PATTERNS:
found = pattern.findall(content)
if found:
matches.extend(found[:3]) # 最多报告 3 个匹配
return (len(matches) > 0, matches)
def sanitize(self, content: str) -> str:
"""脱敏处理:将敏感值替换为占位符"""
result = content
for pattern in SENSITIVE_PATTERNS:
result = pattern.sub("[REDACTED]", result)
return result五、Token 裁剪与预算分配
5.1 预算分配策略
上下文包的 Token 预算按比例分配给三个部分:
@dataclass
class ContextPackage:
"""最终的上下文包"""
files: list[dict] # 文件内容
rules: list[dict] # 规则内容
history: list[dict] # 历史上下文
total_tokens: int
metadata: dict # 生成元数据
class ContextBuilder:
"""上下文包构建器"""
def build(
self,
task_config: dict,
scored_files: list[FileCandidate],
rules: list[RuleFile],
history: list[dict],
) -> ContextPackage:
"""构建最终上下文包"""
budget = task_config["budget"]
total = budget["total_tokens"]
file_budget = int(total * budget["files_allocation"])
rule_budget = int(total * budget["rules_allocation"])
history_budget = int(total * budget["history_allocation"])
# 1. 注入规则(优先保证)
injected_rules = self._inject_rules(rules, rule_budget)
rule_tokens_used = sum(estimate_tokens(r["content"]) for r in injected_rules)
# 2. 分配剩余预算给文件
remaining_for_files = file_budget + max(0, rule_budget - rule_tokens_used)
selected_files = self._select_files(scored_files, remaining_for_files,
task_config.get("max_files", 12))
# 3. 历史上下文
selected_history = self._select_history(history, history_budget)
# 4. 元数据
metadata = {
"total_tokens": sum(
estimate_tokens(f.get("content", "")) for f in selected_files
) + rule_tokens_used + sum(
estimate_tokens(h.get("content", "")) for h in selected_history
),
"files_count": len(selected_files),
"rules_count": len(injected_rules),
"history_count": len(selected_history),
"file_budget": file_budget,
"rule_budget": rule_budget,
}
return ContextPackage(
files=selected_files,
rules=injected_rules,
history=selected_history,
total_tokens=metadata["total_tokens"],
metadata=metadata,
)
def _select_files(
self, files: list[FileCandidate], budget: int, max_files: int
) -> list[dict]:
"""在预算内选择文件,高分文件完整保留,低分文件截断"""
result = []
used = 0
for f in files[:max_files]:
content = (Path(f.path)).read_text(encoding="utf-8", errors="ignore")
tokens = f.token_estimate
if used + tokens <= budget:
# 预算充足,完整保留
result.append({"path": f.path, "content": content, "score": f.score})
used += tokens
elif f.score >= 0.5:
# 高分文件但预算不够,截断
max_chars = (budget - used) * 4 # 粗略转换
if max_chars > 200:
truncated = content[:max_chars] + f"\n... (截断,共 {len(content)} 字符)"
result.append({"path": f.path, "content": truncated, "score": f.score})
used = budget
break
# 低分文件 + 预算不够 → 直接跳过
return result
def _inject_rules(self, rules: list[RuleFile], budget: int) -> list[dict]:
injector = RuleInjector()
injected = injector.inject(rules, budget)
return [{"path": r.path, "content": r.content} for r in injected]
def _select_history(self, history: list[dict], budget: int) -> list[dict]:
"""选择历史上下文(Issue 描述、PR 评论、会话摘要)"""
result = []
used = 0
for h in history:
tokens = estimate_tokens(h.get("content", ""))
if used + tokens <= budget:
result.append(h)
used += tokens
else:
break
return result六、真实经验与踩坑
6.1 文件评分不是静态的
场景:Bugfix 任务中,评分算法把 README.md 排在很前面(因为关键词匹配度高),而真正有问题的 src/auth/token_validator.py 排在后面。
问题:README 关键词密度高但不是修复目标,评分算法对"文档类文件"有天然偏好。
解决:增加文件类型权重调节——源代码文件(.py、.js、.ts)的分数乘以 1.3,文档文件(.md、.txt)乘以 0.6,配置文件(.yaml、.json)乘以 0.8。
FILE_TYPE_WEIGHTS = {
".py": 1.3, ".js": 1.3, ".ts": 1.3, ".tsx": 1.3,
".go": 1.3, ".rs": 1.3, ".java": 1.3,
".md": 0.6, ".txt": 0.5,
".yaml": 0.8, ".json": 0.8, ".toml": 0.8,
".html": 0.9, ".css": 0.9,
}
def adjust_score(candidate: FileCandidate) -> float:
ext = Path(candidate.path).suffix.lower()
weight = FILE_TYPE_WEIGHTS.get(ext, 1.0)
return candidate.score * weight6.2 Token 估算偏差导致上下文溢出
场景:我们最初用"1 token ≈ 4 chars"来估算,但混合中英文的代码库偏差达到 30%。一次 Feature 任务的上下文包实际达到 9,200 tokens,远超 8,000 的预算,导致 Agent 的系统提示被截断。
问题:Token 估算不准确,尤其是中文字符、代码符号、注释混合的文件。
解决:使用模型提供的 tiktoken 库做精确计数,仅在无法调用时用粗略估算作为 fallback。
try:
import tiktoken
encoder = tiktoken.encoding_for_model("claude-sonnet-4-20250514")
def estimate_tokens(content: str) -> int:
return len(encoder.encode(content))
except ImportError:
def estimate_tokens(content: str) -> int:
cjk = sum(1 for c in content if '\u4e00' <= c <= '\u9fff')
ascii_len = len(content) - cjk
return int(ascii_len / 4 + cjk / 1.5)6.3 历史上下文比代码更有价值
场景:一个复杂的 Bugfix 任务,上下文包中包含了 8 个源代码文件但没有 Issue 的历史讨论。Agent 花了 6 轮才定位到根因。后来我们把 Issue 的 5 条关键评论加入上下文包,Agent 只用 2 轮就修复了。
问题:我们过度侧重代码文件,忽略了 Issue 讨论、PR 评论中包含的诊断信息。
解决:调整预算分配——对于 Bugfix 任务,历史上下文的预算从 15% 提升到 25%,代码文件从 60% 降到 50%。历史评论按"诊断价值"排序:包含错误日志、堆栈跟踪、复现步骤的评论优先级最高。
七、参数说明表
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
budget.total_tokens |
Integer | 6000 |
上下文包总 Token 预算 |
budget.files_allocation |
Float | 0.60 |
文件内容占总预算比例 |
budget.rules_allocation |
Float | 0.20 |
规则文件占总预算比例 |
budget.history_allocation |
Float | 0.15 |
历史上下文占总预算比例 |
scoring.keyword_match |
Float | 0.35 |
关键词匹配权重 |
scoring.import_distance |
Float | 0.25 |
导入链路距离权重 |
scoring.change_frequency |
Float | 0.15 |
变更频率权重 |
scoring.issue_reference |
Float | 0.15 |
Issue 直接提及权重 |
scoring.test_proximity |
Float | 0.10 |
测试文件邻近权重 |
max_files |
Integer | 12 |
上下文包最多包含的文件数 |
min_files |
Integer | 3 |
最少包含的文件数 |
required_files |
List | — | 必须包含的文件模式列表 |
sensitive_patterns |
List | 内置 | 敏感信息正则模式列表 |
sensitive_files |
List | 内置 | 敏感文件路径通配符列表 |
rule_layers |
List | 7 层 | 规则文件分层配置(路径、优先级、最大行数) |
八、落地检查清单
- 上下文最小化:上下文包只包含与任务直接相关的文件,不塞入"以防万一"的内容
- 敏感信息过滤:
.env、密钥文件、生产配置等敏感文件被完全排除 - 敏感内容脱敏:即使非敏感文件中也不包含硬编码的密码、Token、连接串
- 规则注入完整:CLAUDE.md 和核心规则始终包含在上下文包中
- Token 预算不超:上下文包总 Token 数不超过配置的预算上限
- 文件评分可解释:每个被选中的文件都有明确的评分原因(关键词匹配、导入距离等)
- 截断有标记:被截断的文件内容末尾有明确的截断标记和原始行数
- 历史上下文包含:Issue 描述和关键评论已包含在历史上下文中
- 验证命令可用:上下文包生成后可以运行验证脚本检查完整性和 Token 数
- 配置文件化:不同任务类型(bugfix / feature / review)有独立的上下文包模板