SHERLOC框架:基于结构化诊断提升LLM代码修复的精准定位能力

📅 2026/8/23 10:22:24
SHERLOC框架:基于结构化诊断提升LLM代码修复的精准定位能力
1. 项目概述当代码修复遇上“福尔摩斯”最近在折腾大语言模型LLM驱动的代码修复代理Code Repair Agents时我遇到了一个几乎所有从业者都会头疼的经典问题定位不准。模型可能准确地理解了“修复一个导致空指针异常的bug”这个指令但它给出的补丁却常常“指东打西”——修改了无关的文件或者在正确的文件里修改了错误的函数。这就像让一个顶尖的外科医生蒙着眼睛做手术他知道病症却找不到准确的病灶位置。为了解决这个“定位”难题一个名为SHERLOC的研究框架进入了我的视野。这个名字起得相当巧妙它不仅是“结构化诊断定位”的缩写更直接借用了神探福尔摩斯Sherlock Holmes的意象其核心使命就是为代码修复代理赋予精准的“侦查”能力在庞大的代码库中像侦探一样锁定真正的缺陷所在。简单来说SHERLOC 不是一个全新的修复模型而是一个增强定位能力的诊断框架。它被设计用来集成到现有的代码修复工作流中特别是在面对像 SWE-Bench 这类包含真实世界、跨文件复杂缺陷的评测基准时其价值尤为突出。SWE-Bench 里的任务可不是玩具问题它们直接来自 GitHub 上开源项目的真实 issue 和 PR修复往往需要理解多个文件之间的复杂交互。传统的、依赖单一文件或简单检索的定位方法在这里很容易“翻车”。SHERLOC 的提出正是为了系统化地解决这个瓶颈它通过结构化的分析引导 LLM 更可靠地找到需要修改的精确位置文件、函数、甚至代码行从而让后续的修复生成步骤能够有的放矢。如果你正在研究或应用 LLM 进行自动化代码修复、程序分析或者对提升 AI 编程助手的精准度感兴趣那么理解 SHERLOC 的设计思路和实现细节将会为你打开一扇新的大门。它不仅仅是一个工具更代表了一种将系统性程序分析与大模型推理能力相结合的重要范式。接下来我将结合自己的实践和理解深入拆解 SHERLOC 的架构、原理以及如何将其思想应用到你的项目中。2. SHERLOC 核心架构与设计哲学SHERLOC 的整个设计围绕一个核心信念代码缺陷的定位不是一个简单的“检索”问题而是一个需要多层次、结构化推理的“诊断”过程。它模仿了优秀工程师的调试思路先根据错误信息或失败测试确定大致范围然后通过追溯函数调用、数据流、依赖关系等线索逐步缩小范围最终定位到根源。为了实现这一点SHERLOC 构建了一个模块化的、可迭代的诊断流水线。2.1 分层定位策略从宏观到微观SHERLOC 的定位过程不是一步到位的而是分层次展开这有效避免了 LLM 一次性处理过多信息导致的混乱。第一层项目范围的文件检索。输入是自然语言描述的问题如 issue 正文和失败的测试用例输出。在这一步目标不是找到精确的行而是筛选出所有可能相关的源文件。SHERLOC 通常会利用基于语义的检索工具如 BM25、基于代码训练的嵌入模型从整个代码库中召回一个候选文件列表。这里的关键在于“广撒网”确保真正的缺陷文件在候选集中即使排名不一定最靠前。在我的实验中单纯依赖向量检索的 Top-1 准确率在复杂项目上可能不足 50%但 Top-10 的召回率往往能超过 90%。因此这一步的设计宽容度很高。第二层跨文件的依赖图构建与分析。这是 SHERLOC 区别于简单检索方法的核心。它不会孤立地看待上一步检索到的每个文件而是动态地构建这些候选文件之间的调用关系图Call Graph和导入依赖关系。例如文件 A 中的函数foo()调用了文件 B 中的函数bar()而测试失败恰恰发生在foo()的执行路径上。那么即使缺陷实际在bar()中通过依赖关系也能将 B 文件的重要性权重提高。SHERLOC 会利用静态分析工具提取这些关系并将其转化为结构化的提示Prompt信息喂给 LLM引导它进行关系推理。这相当于给了 LLM 一张“代码地图”。第三层文件内的细粒度定位。在确定了高度可疑的少数几个文件后进入微观侦查阶段。此时SHERLOC 会将文件的完整代码、相关的失败测试代码、以及从依赖分析中得到的上下文如“函数X在此处被调用”一起呈现给 LLM。任务变为请在这段具体的代码中找出最可能导致测试失败的代码行或代码块。这个过程可能涉及对代码进行抽象语法树AST级别的标注以帮助模型理解代码结构。通常LLM 会被要求以特定格式如返回行号范围输出其判断。注意这三个层次并非总是线性执行。SHERLOC 框架支持迭代反馈。例如在细粒度定位中如果置信度不高可以回溯到依赖分析层扩大相关文件的搜索范围形成一个“假设-验证”的循环这与人类的调试过程非常相似。2.2 结构化提示工程将知识注入推理链如何让 LLM 有效地利用上述结构化信息SHERLOC 依赖于精心设计的提示模板。这些模板不仅仅是简单地把代码和问题拼接起来而是明确地指导 LLM 扮演一个“代码侦探”的角色并遵循特定的推理步骤。一个典型的提示结构可能包含角色定义“你是一个经验丰富的软件工程师正在调试一个缺陷。你的任务是定位问题的根本原因。”问题描述清晰地陈述 issue 和测试失败信息。上下文提供“以下是经过分析可能与问题相关的源文件。文件 A 和文件 B 之间存在调用关系A.foo() - B.bar()。失败的测试用例test_foo_failure主要执行路径涉及 A.foo()。”指令结构化“请按顺序思考a) 分析测试失败的根本原因类型如空指针、逻辑错误、条件边界。b) 根据提供的依赖关系判断问题更可能出现在文件 A 还是文件 B。c) 在你认为最有可能的文件中列出可疑的代码行号并解释理由。”输出格式要求强制要求以 JSON 格式输出包含primary_suspect_file、suspicious_lines、reasoning等字段。通过这种结构化的提示我们将程序的静态分析知识依赖图和动态执行信息测试失败有机地整合到了 LLM 的推理上下文中极大地约束了生成空间提高了定位的准确性和可靠性。2.3 与修复代理的集成定位即服务SHERLOC 被设计为一个独立的“定位模块”。在完整的代码修复智能体Agent工作流中它可以作为前置组件。工作流如下接收问题报告和测试套件。SHERLOC 模块启动执行分层定位输出一个包含高置信度缺陷位置文件、行号的诊断报告。将诊断报告与原始问题一起传递给代码修复生成模块通常是另一个 LLM。此时的提示词变为“在[文件X]的[第Y-Z行]附近存在一个导致[具体问题]的缺陷。请基于以下完整代码上下文生成一个正确的修复补丁。”修复生成模块专注于小块、高确定性的代码生成成功率自然大幅提升。这种“先定位后修复”的职责分离架构符合软件工程中的“单一职责原则”使得每个组件更容易优化和评估。实验数据表明在 SWE-Bench 等基准上一个强大的修复模型如 GPT-4配合精准的 SHERLOC 定位其修复成功率比直接让该模型进行“端到端”的修复有显著提升有时甚至能达到两位数的百分比增长。3. 关键技术实现与工具链选型要将 SHERLOC 的思想付诸实践需要一套具体的工具和方法。以下是我在复现和实验过程中积累的一些关键技术选型与实现要点。3.1 静态分析工具的选择与应用构建代码依赖图是 SHERLOC 的基石。选择哪款静态分析工具取决于目标编程语言和我们对分析深度与速度的权衡。对于 Python 项目tree-sitter是一个高效且通用的选择。它支持多种语言能够快速生成 AST抽象语法树我们可以通过遍历 AST 来提取函数定义、函数调用和导入语句。对于更复杂的分析如跨文件的过程间分析pyan或understand是更专业的工具但它们可能更重设置更复杂。实操心得对于大多数应用场景基于tree-sitter的轻量级解析已经足够。关键是准确提取“函数调用”关系。你需要编写遍历逻辑当遇到一个Call节点时解析出被调用的函数名然后与当前文件内的函数定义或导入的模块进行匹配。对于跨文件匹配需要维护一个项目级的符号表。对于 Java 项目Soot、WALA或JavaParser是工业级的选择。它们能够构建更精确的调用图Call Graph区分虚函数调用等。对于 JavaScript/TypeScript 项目ts-morph或Babel的解析器套件是不错的选择。注意静态分析无法处理动态语言特性如 Python 的eval()、反射或基于运行时配置的调用。SHERLOC 的论文中也提到他们的依赖图是“最佳努力”的可能存在遗漏。在实践中我们需要接受这种不完美并将其作为模型需要处理的不确定性之一。3.2 检索增强的精准化策略第一层的文件检索直接使用原始的 TF-IDF 或简单的词袋模型效果有限。结合代码特性的增强检索至关重要。混合检索策略结合关键词检索如 BM25和语义检索如 Sentence-BERT、CodeBERT 生成的嵌入。BM25 对匹配具体的 API 名、错误信息中的标识符很有效语义检索则能捕捉“处理用户身份验证”与login.py、auth.py之间的抽象关联。将两者的结果进行加权融合如 Reciprocal Rank Fusion。检索单元的优化不是以整个文件作为检索单元可以尝试以“函数”或“类”为单位。这样能提供更细粒度的上下文减少单个单元内的信息噪音。在 SHERLOC 的迭代中可以先检索到文件再在文件内部对函数进行排序。利用测试信息失败的测试用例本身是黄金线索。检索时可以将测试用例的代码、测试函数名以及断言失败信息作为查询的一部分这能极大地提升与缺陷相关文件的检索排名。3.3 LLM 的调用与提示设计实战这里以使用 OpenAI GPT-4 或 Claude 3 等大型模型为例说明如何具体实现 SHERLOC 的提示。步骤一构建依赖上下文字符串。假设通过静态分析我们找到了三个可疑文件main.pyutils.pyvalidator.py。并发现关系main.py中的process_data()调用了utils.py中的sanitize_input()而sanitize_input()又使用了validator.py中的is_valid()。 我们需要将这段关系转化为自然语言描述代码依赖关系分析 - 文件 main.py 中的函数 process_data 在第45行调用了 utils.sanitize_input。 - 文件 utils.py 中的函数 sanitize_input 在第22行调用了 validator.is_valid。 测试 test_process_invalid_data 的执行路径覆盖了 process_data。步骤二组装多文件代码上下文。将三个文件的源代码分别用清晰的标记包裹后拼接。为防止上下文过长可以只截取相关函数及其周围若干行代码而非整个文件。// 文件: main.py def process_data(input): ... cleaned sanitize_input(input) # 第45行调用点 ... // 文件: utils.py def sanitize_input(data): ... if not is_valid(data): # 第22行调用点 raise ValueError(Invalid input) ... // 文件: validator.py def is_valid(item): return item is not None and item ! # 第10行关键逻辑步骤三编写结构化提示。system_prompt 你是一个资深软件调试专家。你的任务是分析测试失败报告并精准定位导致失败的源代码缺陷位置。请严格遵循以下推理步骤。 user_prompt f ## 问题描述 Issue: 当输入为空字符串时process_data 函数没有抛出预期的 ValueError而是返回了错误结果。 测试失败信息: test_process_invalid_data 断言失败期望抛出 ValueError 但实际未抛出。 ## 代码上下文与依赖关系 {dependency_context} ## 相关源代码 {code_context} ## 你的任务 请执行以下步骤 1. 推理缺陷类型根据问题描述判断最可能是哪类错误例如空值处理遗漏、条件逻辑错误、异常处理不当等。 2. 分析依赖链结合提供的依赖关系推理问题最可能出现在哪个文件的哪个函数中。解释你的推理过程。 3. 精确定位在最终确定的函数中指出具体的可疑代码行号1-3行并说明为什么这几行代码可能导致所述问题。 ## 输出格式 请以以下 JSON 格式输出不要包含任何其他解释 {{ defect_type: 你的推理, primary_suspect_file: 文件名.py, primary_suspect_function: 函数名, suspicious_line_numbers: [开始行, 结束行], reasoning: 你的逐步推理过程引用依赖关系和代码细节 }} 通过这样的提示LLM 的输出就被严格约束在了我们需要的结构化信息上极大地方便了后续的自动化处理。4. 实战演练构建一个简易的 SHERLOC 诊断管道理论说了这么多我们来动手搭建一个针对 Python 项目的简化版 SHERLOC 管道。这个例子将涵盖从代码解析到 LLM 定位的核心流程。4.1 环境准备与依赖安装首先创建一个新的 Python 环境并安装必要库。# 创建并激活虚拟环境可选 python -m venv sherloc_env source sherloc_env/bin/activate # Linux/Mac # sherloc_env\Scripts\activate # Windows # 安装核心库 pip install tree-sitter tree-sitter-python # 用于解析Python代码 pip install rank-bm25 # 用于关键词检索 pip install openai # 用于调用GPT API或其他你选择的LLM SDK pip install pytest # 用于运行测试如果需要4.2 实现代码解析与依赖图构建我们使用tree-sitter来解析单个 Python 文件提取函数和调用。import os from tree_sitter import Language, Parser import re # 加载Python语法 PYTHON_LANGUAGE Language(vendor/tree-sitter-python.so, python) # 需要先编译详见tree-sitter文档 parser Parser() parser.set_language(PYTHON_LANGUAGE) def extract_functions_and_calls(file_path): 解析单个Python文件提取函数定义和函数调用。 返回: {functions: [{name:..., start_line:..., end_line:...}], calls: [{caller_line:..., callee_name:...}]} with open(file_path, r, encodingutf-8) as f: code f.read() tree parser.parse(bytes(code, utf-8)) root_node tree.root_node functions [] calls [] # 遍历AST查找函数定义和调用 # 这里是一个简化示例实际需要更细致的遍历逻辑 def traverse(node): if node.type function_definition: name_node node.child_by_field_name(name) if name_node: func_name code[name_node.start_byte:name_node.end_byte] functions.append({ name: func_name, start_line: node.start_point[0] 1, end_line: node.end_point[0] 1, file: file_path }) elif node.type call: # 提取被调用函数名简化处理实际可能更复杂 func_node node.child_by_field_name(function) if func_node: callee_name code[func_node.start_byte:func_node.end_byte] calls.append({ caller_line: node.start_point[0] 1, callee_name: callee_name, file: file_path }) for child in node.children: traverse(child) traverse(root_node) return {functions: functions, calls: calls} def build_project_graph(project_root): 遍历项目目录构建初步的调用关系图。 返回一个字典记录文件间的调用关系。 graph {} for root, dirs, files in os.walk(project_root): for file in files: if file.endswith(.py): file_path os.path.join(root, file) data extract_functions_and_calls(file_path) # 简化这里只记录文件级别的调用关系通过导入和调用名推断 # 更完善的实现需要解析import语句和进行符号解析 graph[file_path] data # 此处应添加逻辑将calls中的callee_name解析为具体的定义文件 # 这需要处理导入import和模块路径是一个复杂的挑战。 # 作为演示我们假设一个简单的映射。 return graph实操心得跨文件的精确调用图构建是静态分析中的难点。在简易版中我们可以通过解析import语句建立一个模块到文件的映射表然后将调用名与导入的模块/函数进行匹配。对于大型项目建议使用更成熟的静态分析库如jedifor Python来完成这部分工作或者采用启发式方法如通过函数名在项目所有函数中搜索定义来建立近似关联。4.3 集成检索与 LLM 诊断假设我们已经有了一个失败测试的报错信息error_msg和相关的测试文件test_file。import openai from rank_bm25 import BM25Okapi import json class SimpleSherloc: def __init__(self, project_graph, llm_client): self.graph project_graph self.llm llm_client # 为检索准备语料库文件路径 - 文件内容或函数摘要 self.corpus [] self.file_paths [] for file_path, data in project_graph.items(): with open(file_path, r, encodingutf-8) as f: content f.read() # 可以只取前几行或生成摘要作为检索单元 self.corpus.append(self._generate_file_summary(file_path, data, content)) self.file_paths.append(file_path) self.bm25 BM25Okapi([doc.split() for doc in self.corpus]) def _generate_file_summary(self, file_path, data, content): 生成文件的文本摘要用于检索。 func_names [f[name] for f in data[functions]] summary fFile: {os.path.basename(file_path)}. Functions: {, .join(func_names)}. Content preview: {content[:500]} return summary def retrieve_suspicious_files(self, issue_text, top_k5): 基于问题描述检索最相关的文件。 tokenized_query issue_text.split() scores self.bm25.get_scores(tokenized_query) top_indices scores.argsort()[-top_k:][::-1] return [self.file_paths[i] for i in top_indices] def diagnose(self, issue_text, test_error): 执行诊断流程。 # 步骤1: 文件检索 candidate_files self.retrieve_suspicious_files(issue_text test_error, top_k3) print(f检索到的候选文件: {candidate_files}) # 步骤2: 构建依赖上下文简化版只考虑候选文件间关系 dep_context self._build_dependency_context(candidate_files) # 步骤3: 准备代码上下文 code_context for file in candidate_files: with open(file, r, encodingutf-8) as f: code_context f\n// 文件: {file}\n{f.read()}\n # 步骤4: 调用LLM进行诊断 diagnosis_prompt self._construct_prompt(issue_text, test_error, dep_context, code_context) response self.llm.chat.completions.create( modelgpt-4, messages[ {role: system, content: 你是一个代码调试助手。}, {role: user, content: diagnosis_prompt} ], temperature0.1 # 低温度保证输出确定性 ) result response.choices[0].message.content # 解析JSON结果 try: diagnosis json.loads(result) return diagnosis except json.JSONDecodeError: print(LLM 返回了非 JSON 格式。) return {raw_response: result} def _build_dependency_context(self, files): # 简化实现从graph中提取这些文件之间的调用关系 context_lines [] for file in files: if file in self.graph: for call in self.graph[file].get(calls, []): callee call[callee_name] # 这里应该解析callee属于哪个文件简化处理 # 假设我们能找到定义 for other_file in files: if other_file ! file and self._is_defined_in(other_file, callee): context_lines.append(f- 文件 {file} 的第{call[caller_line]}行调用了 {callee} (定义于 {other_file})。) return \n.join(context_lines) if context_lines else 未发现明显的跨文件调用关系。 def _is_defined_in(self, file_path, symbol): # 简化检查符号是否出现在该文件的函数名或导入中 data self.graph.get(file_path, {}) return any(symbol f[name] for f in data.get(functions, [])) def _construct_prompt(self, issue, error, dep_context, code_context): # 构建如前文所述的详细提示词 prompt_template f [问题描述] {issue} [测试错误信息] {error} [代码依赖关系分析] {dep_context} [相关源代码] {code_context} 请分析并定位缺陷。输出必须是以下JSON格式 {{ defect_type: ..., primary_suspect_file: ..., primary_suspect_function: ..., suspicious_line_numbers: [start, end], reasoning: ... }} return prompt_template # 使用示例 if __name__ __main__: # 假设已初始化 project_graph 和 openai client # sherloc SimpleSherloc(project_graph, openai.Client(api_keyyour-key)) # issue 处理空输入时程序崩溃。 # test_error AssertionError: Expected ValueError but got None. # result sherloc.diagnose(issue, test_error) # print(result) pass这个简易管道实现了 SHERLOC 的核心思想检索 - 依赖分析 - 结构化提示 - LLM 诊断。你可以在此基础上增强静态分析的准确性优化检索策略并完善错误处理。5. 性能评估、常见陷阱与优化策略在 SWE-Bench 等基准测试上评估 SHERLOC 类方法通常会关注两个核心指标定位准确率和端到端修复成功率。定位准确率衡量的是诊断出的缺陷位置文件行号与真实补丁位置的匹配程度修复成功率则衡量在准确定位后生成正确补丁的能力。5.1 评估指标解读定位精度 (Localization Precision/Recall)这不仅仅是“文件级”的准确率更是“行级”的准确率。一个理想的定位系统应该能精确到缺陷所在的代码行或小块区域。在评估时可以设定一个容忍范围如上下5行内。SHERLOC 的优势在于通过结构化推理其行级定位的精确度通常远高于仅靠代码嵌入检索的方法。修复成功率 (Fix Rate)这是终极目标。通常我们会对比基线模型直接让强大的 LLM如 GPT-4进行端到端修复。模型 检索为 LLM 提供通过检索得到的最相关的几个文件。模型 SHERLOC为 LLM 提供由 SHERLOC 产出的、包含精准定位和依赖关系的诊断报告。 实验结果表明方案3往往能显著提升方案1和2的成功率尤其是在涉及多文件修改的复杂任务上。5.2 实践中的常见陷阱与解决方案在实现和应用 SHERLOC 思想时我踩过不少坑这里分享几个关键点静态分析的局限性陷阱动态调用如通过字符串拼接函数名、使用getattr、装饰器、元编程等会让静态分析失效导致依赖图不完整。解决方案承认并接受这种不完美。在提示词中告知 LLM “依赖图可能不完整”并鼓励它结合代码语义进行推理。同时可以探索轻量级的动态分析如通过测试覆盖信息作为补充。LLM 的“幻觉”与不一致性陷阱即使提供了精确的依赖关系LLM 有时仍会“臆造”不存在的调用或忽略关键线索。解决方案降低温度 (Temperature)在诊断阶段使用较低的温度如 0.1以提高输出的确定性和一致性。自我验证 (Self-Consistency)让 LLM 对同一个问题诊断多次然后投票或选择最一致的结果。后处理校验对 LLM 输出的定位结果进行简单校验例如检查它指出的行号是否在文件范围内提到的函数名是否真实存在。上下文长度与成本陷阱将多个文件的完整代码和依赖描述塞进提示词很容易超出模型的上下文窗口且 token 消耗巨大。解决方案智能截断只包含候选文件中与可疑函数相关的代码段而不是整个文件。分层摘要先让 LLM 对单个文件生成摘要如“这个文件主要负责用户认证”在高层推理时使用摘要只在最终精确定位时注入详细代码。利用长上下文模型虽然成本高但对于关键任务使用支持 128K 或更长上下文的模型是直接有效的方案。对测试信息的利用不足陷阱仅把测试失败信息作为问题描述的一部分没有深度挖掘测试代码本身的价值。解决方案将失败的测试用例代码作为一级检索和推理对象。测试用例直接指明了被测试的函数、预期的输入输出和行为是定位缺陷最直接的线索。可以将测试用例中的断言、模拟mock设置等信息显式地提取出来加入提示词。5.3 进阶优化策略当你掌握了基础实现后可以考虑以下方向进行优化迭代式精炼 (Iterative Refinement)不让 SHERLOC 只运行一次。如果第一次定位的置信度不高例如LLM 输出的reasoning字段显得犹豫或矛盾可以设计一个反馈循环。基于第一次的结果扩大或缩小文件检索范围修改依赖查询进行第二次、第三次诊断直到达成一个高置信度的共识。多智能体协作可以设计多个具有不同专长的“侦探”智能体。一个擅长分析数据流一个擅长理解控制流另一个擅长解析异常处理。让它们分别对同一问题给出诊断意见再由一个“主侦探”智能体进行综合判断。这种“委员会”机制能有效降低单一模型的偏差。与修复阶段的深度集成SHERLOC 的诊断报告不应只是简单的行号。可以将其丰富为一种“修补计划”例如“缺陷类型空指针解引用。修复策略在第 X 行添加空值检查if param is None:。” 然后将这个计划传递给修复生成器进一步约束其生成方向。持续学习与反馈在实际部署中记录每次定位和修复的成功与失败案例。可以用这些数据对检索模型进行微调或者构建一个“常见缺陷模式-定位策略”的映射库用于未来类似问题的快速匹配。SHERLOC 框架为我们提供了一个强大的蓝图它证明了将经典的软件工程分析技术与现代大语言模型相结合可以产生“112”的效果。它解决的定位问题是通往完全自动化、高可靠性代码修复道路上的一个关键路障。虽然完全复现论文中的系统需要大量的工程工作但其核心思想——结构化、多层次的诊断推理——可以被灵活地应用到我们现有的 AI 编程助手或代码质检流程中立即带来可见的效能提升。从我个人的体验来看即使是一个简化版的实现也能在处理那些令人抓狂的、跨文件的隐蔽 Bug 时提供远超传统搜索的精准指引。