Agent 的输出质量取决于输入质量——给它精确的上下文,它就能写出精准的代码;给它模糊的上下文,它只能猜。本文设计一套上下文包生成器,从仓库文件、Issue 描述、PR 变更、历史会话中自动筛选、评分、裁剪,生成 Token 最优的上下文包。

Agent 上下文包生成器:文件选择、规则注入与 Token 裁剪

Agent 的输出质量取决于输入质量——给它精确的上下文,它就能写出精准的代码;给它模糊的上下文,它只能猜。本文设计一套上下文包生成器,从仓库文件、Issue 描述、PR 变更、历史会话中自动筛选、评分、裁剪,生成 Token 最优的上下文包。

目录

一、上下文包是什么

Agent 执行任务时,模型需要"看到"足够的代码和规则才能给出正确答案。但"看到"不是越多越好——模型有上下文窗口限制,塞入太多无关内容不仅浪费 Token,还会稀释关键信息的注意力权重。

上下文包(Context Package)是 Agent 执行任务前,由系统自动组装的一份最小充分上下文,包含:

  • 必需文件:任务直接涉及的源代码文件
  • 相关文件:与任务间接相关的依赖文件、接口定义、测试文件
  • 项目规则:CLAUDE.md、编码规范、架构文档
  • 历史上下文:关联的 Issue 描述、PR 评论、之前的 Agent 会话摘要

1.1 没有上下文包的 Agent 是怎么工作的

text
任务:修复 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 轮

二、上下文包生成流程

上下文包的生成分为四个阶段:收集 → 评分 → 过滤 → 裁剪。

text
                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 上下文包配置

每个任务类型可以定义不同的上下文包模板:

yaml
# 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: 5

3.2 文件评分函数

python
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 预算充足时才包含:

python
@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 result

4.2 敏感信息过滤

上下文包中绝对不能包含敏感信息。过滤器在文件收集阶段就会排除:

python
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 预算按比例分配给三个部分:

python
@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。

python
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 * weight

6.2 Token 估算偏差导致上下文溢出

场景:我们最初用"1 token ≈ 4 chars"来估算,但混合中英文的代码库偏差达到 30%。一次 Feature 任务的上下文包实际达到 9,200 tokens,远超 8,000 的预算,导致 Agent 的系统提示被截断。

问题:Token 估算不准确,尤其是中文字符、代码符号、注释混合的文件。

解决:使用模型提供的 tiktoken 库做精确计数,仅在无法调用时用粗略估算作为 fallback。

python
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)有独立的上下文包模板