Week 4 -- Day 2:项目二 — 代码审查助手

Day 2:项目二 — 代码审查助手

项目背景与架构总览

代码审查是软件工程中保障质量的核心实践。无论是开源社区的 Pull Request 流程,还是企业内部的 CI/CD 流水线,一段代码在被合并到主分支之前通常需要至少一位同行进行人工审查。人工审查的优势在于审查者能够理解业务上下文、发现逻辑缺陷和设计问题,但同时也存在明显的局限性:审查速度受限于审查者的可用时间、审查标准因人而异、安全漏洞和风格问题容易被疲劳的眼睛遗漏。一个理想的代码审查助手不是要替代人工审查者,而是承担起那些机械、重复、规则明确的检查任务,将静态分析、安全扫描和风格检查的结果整合为一份结构化的预审查报告,让人工审查者可以把精力集中在逻辑正确性和架构合理性等更高价值的工作上。

我们的代码审查助手系统采用"先并行分发、再汇总报告"的架构设计。一条流水线被分为两个阶段,分发阶段和汇总阶段。在分发阶段,用户提交的代码被同时派发到三个独立的审查维度,静态分析关注代码结构与复杂度的量化指标、安全检查聚焦于常见漏洞模式与危险函数调用、风格审查则根据语言规范评估命名、注释和格式的一致性。这三个维度的审查任务之间没有任何数据依赖关系,静态分析不需要等待安全检查的结果,安全检查也不需要引用风格审查的输出,因此它们天然适合在同一个并发批次中并行执行。在汇总阶段,一个专门的报告生成节点接收三个维度各自产出的审查结果,调用大语言模型将这些碎片化的发现整合为一篇条理清晰、有优先级排序、附带改进建议的完整审查报告。

在技术选型上,我们使用 LangChain 的 @tool 装饰器将每个审查维度封装为一个标准工具,工具的 docstring 即为模型判断何时调用该工具的依据。使用 create_agent 创建审查 Agent,它内置了完整的 ReAct 推理循环,能够自主决定调用哪些工具以及以何种顺序调用。使用 LangGraph 的 StateGraph 构建整个工作流,其中最关键的并行分发机制由 LangGraph 原生的 Send API 实现——分发节点在检查完代码后返回一个 Send 对象列表,每个 Send 对象携带一份代码副本和一个目标节点名称,LangGraph 的运行时会在同一个 super-step 中将这三个 Send 并发调度到各自的目标节点上执行。这种设计不仅减少了端到端的等待时间,还使得审查维度可以随时扩展,当需要新增一个依赖分析维度或测试覆盖率维度时,只需要新增一个 Send 对象和对应的节点函数,无需修改任何现有节点的逻辑。

开发环境与模型初始化

在开始编码之前,确保已安装以下核心依赖。LangGraph 是整个工作流编排的基础框架,langchain.agents 中的 create_agent 函数提供了创建具备工具调用能力的 Agent 的最快路径。langchain-openai 提供 Chat 模型的统一接口,我们使用的 DeepSeek 和硅基流动等平台均兼容 OpenAI 的 API 协议,通过将 model_provider 设置为 "openai" 即可用同一套代码对接不同服务商。

# 核心依赖安装
pip install langgraph langchain langchain-openai python-dotenv

模型初始化统一使用 init_chat_model,这是 LangChain 推荐的模型工厂函数。代码审查任务对模型的推理能力和代码理解能力要求较高,因此我们选用 DeepSeek-V4-Pro 作为主力模型。temperature=0 的设置对于代码审查场景尤为重要,审查结果需要高度一致性和可复现性,同一段代码每次提交审查应当得到相同的结论,而不是因为采样随机性而产生时好时坏的评分。此外,通过 model_kwargs 传入 extra_body 参数可以开启模型的思考模式(thinking),为复杂的安全漏洞分析和重构建议提供更深层的推理链。

from langchain.chat_models import init_chat_model
from dotenv import load_dotenv

load_dotenv()

llm = init_chat_model(
    "deepseek-ai/DeepSeek-V4-Pro",
    model_provider="openai",
    temperature=0,
    model_kwargs={"extra_body": {"thinking": {"type": "enabled"}}}
)

这段初始化代码是整个审查助手的引擎入口。init_chat_model 的第一个参数指定了模型标识,格式为 "提供商/模型名",当 model_provider 设为 "openai" 时,LangChain 底层使用 ChatOpenAI 类发起 HTTP 请求,但实际请求的目标 URL 由环境变量 OPENAI_BASE_URL 决定,同样的代码既可以指向 DeepSeek 的服务器,也可以指向硅基流动或任何兼容 OpenAI 协议的网关。temperature=0 将模型的采样温度降到最低,本质上是告诉模型在每一步生成时都选择概率最高的 token,去除随机性。这在代码审查中至关重要,如果一段包含 SQL 注入风险的代码被提交审查,我们期望模型每次都稳定地标记出这个漏洞并给出一致的严重等级,而不是偶尔漏过。extra_body 中的 thinking 配置会随每次请求一起发送到模型服务端,触发模型的深度推理模式,在这个模式下模型会先进行一段内部思维链推演再输出最终结果,对于需要综合多种因素才能做出判断的审查任务(例如判断一段复杂业务逻辑是否存在并发安全问题),思维链能够显著提升审查质量。

定义审查工具:静态分析、安全检查与风格审查

工具是 Agent 感知世界和改变世界的触角。在 LangChain 中定义一个工具最直接的方式是使用 @tool 装饰器,它会自动将函数的签名和 docstring 转化为模型可以理解的工具描述,包括工具名称、用途说明和参数类型。当模型在推理过程中判断当前任务需要借助外部能力时,它会生成一个 tool_call,其中包含要调用的工具名称和结构化的参数,LangChain 的 Agent 运行时接收到这个 tool_call 后会执行对应的 Python 函数并将结果返回给模型,模型再基于工具的输出继续推理,直到它认为可以给出最终回复为止。

我们首先实现静态分析工具。在真实的 CI/CD 环境中,静态分析通常会对接 pylint、flake8、SonarQube 等成熟的第三方工具,这些工具能够解析 AST、检测代码异味和计算圈复杂度。在这个教学实现中,我们使用 Python 标准库的 ast 模块对代码进行真实的结构分析,同时保留了对接外部工具的接口注释,方便读者在实际项目中替换为生产级方案。

import ast
from langchain.tools import tool


@tool
def static_analysis(code: str) -> str:
    """对给定的 Python 代码执行静态结构分析。检查内容包括:函数与类的数量、
    圈复杂度估算、代码行数统计、潜在的空 except 子句和过长的函数体。
    当需要评估代码的结构质量和复杂度时调用此工具。

    Args:
        code: 待分析的 Python 源代码字符串
    """
    try:
        tree = ast.parse(code)
    except SyntaxError as e:
        return f"[静态分析] 代码存在语法错误,无法继续分析:{e}"

    functions = []
    classes = []
    issues = []

    for node in ast.walk(tree):
        if isinstance(node, ast.FunctionDef):
            functions.append(node.name)
            # 估算圈复杂度:统计分支节点数量
            complexity = 1
            for child in ast.walk(node):
                if isinstance(child, (ast.If, ast.While, ast.For, ast.ExceptHandler, ast.With)):
                    complexity += 1
                elif isinstance(child, ast.BoolOp):
                    complexity += len(child.values)-1
            if complexity > 10:
                issues.append(
                    f"函数 `{node.name}` 圈复杂度为 {complexity},建议拆分"
                )
            if len(node.body) > 50:
                issues.append(
                    f"函数 `{node.name}` 体长 {len(node.body)} 行,过长"
                )
        elif isinstance(node, ast.ClassDef):
            classes.append(node.name)
        elif isinstance(node, ast.ExceptHandler):
            if not node.type is None:
                issues.append(
                    f"第 {node.lineno} 行存在裸 except,应捕获具体异常类型"
                )
    total_lines = len(code.splitlines())
    summary = (
        f"静态分析报告\n"
        f"{'=' * 40}\n"
        f"代码总行数:{total_lines}\n"
        f"函数数量:{len(functions)}({', '.join(functions) if functions else '无'})\n"
        f"类数量:{len(classes)}({', '.join(classes) if classes else '无'})\n"
        f"发现问题:{len(issues)} 个\n"
    )
    if issues:
        summary += "\n".join(f"  - {issue}" for issue in issues)
    else:
        summary += "未发现明显的结构问题。"
    return summary

static_analysis 工具借助 Python 内置的 ast.parse 将源代码字符串解析为抽象语法树,然后通过 ast.walk 遍历树中的每一个节点进行分类统计。对于每个函数定义节点,它会递归遍历函数体内的所有子节点,每遇到一个分支结构(if、for、while、except、with)就将圈复杂度计数器加一,遇到布尔运算节点则根据操作数的数量增加相应的复杂度分值。圈复杂度是衡量代码可测试性和可维护性的重要指标,数值越高意味着函数内部的决策路径越多,测试时需要的用例数量越多,理解成本也越高。这里将阈值设为 10 是一个常见的行业参考值,超过这个值的函数被标记为需要关注。函数体长度的阈值设为 50 行,过长的方法通常意味着职责不单一。裸 except 的检测是静态分析中一个经典的安全与健壮性检查,node.type is None 表示 except 子句没有指定具体的异常类型,这会捕获包括 KeyboardInterruptSystemExit 在内的所有异常,可能掩盖意外的程序错误或造成进程无法正常终止。工具的返回值是结构化的文本报告,模型在收到这份报告后能够直接读取和引用其中的具体行号和函数名,在后续的综合报告中定位到精确的代码位置。

安全检查工具关注代码中可能存在的安全漏洞。在 Web 应用开发中,SQL 注入和跨站脚本攻击(XSS)是最常见的安全威胁,而硬编码的密钥、密码和令牌则是配置管理中的高危反模式。这个工具的检测逻辑基于正则模式匹配,虽然在完整性上不如专业的 SAST(静态应用安全测试)工具,但它捕捉的是最普遍、后果最严重的那一类问题。

import re
from langchain.tools import tool


SQL_INJECTION_PATTERNS = [
    r"(?:execute|cursor)\(\s*[\"'].*%(?:s|d|\(",
    r"(?:execute|cursor)\(\s*f[\"'].*\{",
    r"\.format\(.*\)",
    r"\+.*\"\s*(?:WHERE|SET|VALUES|INTO)",
]

HARDCODED_SECRET_PATTERNS = [
    r"(?:password|passwd|secret|api_key|apikey|token|auth_token)\s*=\s*[\"'][^\"']+[\"']",
    r"(?:private_key|privatekey)\s*=\s*[\"']-{5}BEGIN",
    r"(?:access_key|secret_key)\s*=\s*[\"'][A-Za-z0-9+/]{20,}[\"']",
]

DANGEROUS_FUNCTIONS = ["eval", "exec", "compile", "__import__",
                       "pickle.loads", "yaml.load", "subprocess.call"]


@tool
def security_scan(code: str) -> str:
    """对给定的 Python 代码执行安全漏洞扫描。检测内容包括:SQL 注入风险、
    硬编码的密码/密钥/令牌、危险函数调用(eval/exec/pickle 等)。
    当需要评估代码的安全性时调用此工具。

    Args:
        code: 待扫描的 Python 源代码字符串
    """
    issues = []

    for lineno, line in enumerate(code.splitlines(), start=1):
        # 检测 SQL 注入模式
        for pattern in SQL_INJECTION_PATTERNS:
            if re.search(pattern, line, re.IGNORECASE):
                issues.append(
                    f"[高危] 第 {lineno} 行可能存在 SQL 注入风险:"
                    f"`{line.strip()[:60]}...`"
                )
                break
        # 检测硬编码密钥
        for pattern in HARDCODED_SECRET_PATTERNS:
            if re.search(pattern, line, re.IGNORECASE):
                issues.append(
                    f"[高危] 第 {lineno} 行可能存在硬编码密钥:"
                    f"`{line.strip()[:60]}...`"
                )
                break
        # 检测危险函数调用
        for func in DANGEROUS_FUNCTIONS:
            if func in line:
                issues.append(
                    f"[高危] 第 {lineno} 行存在危险函数调用:"
                    f"`{line.strip()[:60]}...`"
                )
                break

    if issues:
        return (
            f"安全扫描报告\n{'=' * 40}\n"
            f"发现 {len(issues)} 个安全问题:\n" +
            "\n".join(issues)
        )
    return "安全扫描报告\n" + "=" * 40 + "\n未发现明显的安全漏洞。"

安全检查工具的工作方式是按行遍历源代码,将每一行与预定义的正则模式逐一匹配。SQL_INJECTION_PATTERNS 列表中的正则在捕捉那些通过字符串拼接或格式化方法构建 SQL 语句的典型写法,例如 cursor.execute("SELECT * FROM users WHERE id=" + user_id)cursor.execute(f"SELECT * FROM users WHERE id={user_id}"),这些写法在参数未经过滤时会让攻击者有机会注入恶意的 SQL 片段。HARDCODED_SECRET_PATTERNS 则匹配变量赋值语句中将字符串字面量直接赋给密码、密钥或令牌字段的模式,比如 password = "admin123"api_key = "sk-abc123...",这类代码一旦被提交到版本控制系统,密钥就永久暴露在历史记录中,即使后续删除也无济于事。每个发现的问题都被标注了危险等级(高危/中危)和精确的行号,这些信息将在报告汇总阶段被模型用来对问题做优先级排序。

风格审查工具关注代码的可读性和一致性。在一个团队协作的项目中,统一的代码风格能让所有成员更快地理解彼此的代码,减少因格式差异造成的认知摩擦。Python 社区有 PEP 8 作为风格指南,JavaScript 生态有 ESLint,而这个工具的核心思路与它们一致:定义一组可自动检查的规则,逐条匹配并生成反馈。

import re
from langchain.tools import tool


@tool
def style_check(code: str) -> str:
    """对给定的 Python 代码执行风格与可读性检查。检查内容包括:命名规范
    (驼峰/下划线)、注释覆盖率、函数长度、行长度限制、空白行使用等。
    当需要评估代码风格和可维护性时调用此工具。

    Args:
        code: 待检查的 Python 源代码字符串
    """
    lines = code.splitlines()
    issues = []

    for lineno, line in enumerate(lines, start=1):
        stripped = line.strip()

        # 行长度检查
        if len(stripped) > 120:
            issues.append(
                f"[风格] 第 {lineno} 行长度 {len(stripped)} 字符,超过 120 字符限制"
            )

        # 行尾空白检查
        if line != stripped and line.strip():
            issues.append(
                f"[风格] 第 {lineno} 行末尾存在多余空白字符"
            )

    # 统计注释覆盖率
    total_lines = len(lines)
    comment_lines = sum(
        1 for line in lines
        if line.strip().startswith('#') or line.strip().startswith('"""')
    )
    if total_lines > 10:
        ratio = comment_lines / total_lines
        if ratio < 0.05:
            issues.append(
                f"[风格] 注释覆盖率仅 {ratio:.1%},建议补充关键逻辑的注释"
            )
        elif ratio > 0.5:
            issues.append(
                f"[风格] 注释覆盖率 {ratio:.1%} 偏高,部分注释可能冗余"
            )
    # 函数/变量命名规范检查
    for lineno, line in enumerate(lines, start=1):
        # 检查类名是否使用大驼峰
        class_match = re.match(r"^\s*class\s+([a-zA-Z_]\w*)", line)
        if class_match:
            name = class_match.group(1)
            if not name[0].isupper():
                issues.append(
                    f"[风格] 第 {lineno} 行类名 `{name}` 应使用大驼峰命名"
                )
        # 检查函数/变量名是否使用下划线
        def_match = re.match(r"^\s*def\s+([a-zA-Z_]\w*)", line)
        if def_match:
            name = def_match.group(1)
            if any(c.isupper() for c in name):
                issues.append(
                    f"[风格] 第 {lineno} 行函数名 `{name}` 应使用小写+下划线"
                )
    if issues:
        return (
            f"风格审查报告\n{'=' * 40}\n"
            f"发现 {len(issues)} 个风格问题:\n" +
            "\n".join(issues)
        )
    return "风格审查报告\n" + "=" * 40 + "\n代码风格良好,未发现明显问题。"

风格审查工具从三个维度逐层扫描代码。行长度检查设定的阈值为 120 字符,比 PEP 8 推荐的 79 字符更宽松,这是从实际工程经验出发做的调整——现代显示器的宽度早已不需要将行宽限制在 80 字符以内,120 字符的折中值在可读性和代码密度之间取得了更好的平衡,同时也避免了对类型注解丰富的函数签名和描述性变量名的不必要警告。注释覆盖率的检查有一个前提条件,即代码总行数超过 10 行,这样可以避免对微小的脚本文件产生无意义的提醒。覆盖率的理想区间设定在 5% 到 50% 之间,低于下限意味着代码缺乏必要的解释,高于上限则可能意味着代码中有大量被注释掉的旧代码或者注释在重复描述显而易见的逻辑。命名规范的检查使用正则表达式匹配 defclass 声明语句,从捕获组中提取函数名或类名,再根据 Python 社区的 PascalCase(类名)和 snake_case(函数名)约定做逐字检查,这是一个简单但非常实用的自动化把关。

三个工具各司其职,覆盖了代码审查中安全、结构和可读性这三个核心维度。它们的共同特点是输入参数完全一致,都只接受一个 code: str,在工作流的分发阶段,三个工具可以共享同一份输入数据而不需要任何格式转换。每个工具的 docstring 都精心撰写了触发条件描述,例如安全工具的"当需要评估代码的安全性时调用此工具",静态分析工具的"当需要评估代码的结构质量和复杂度时调用此工具"。当这三个工具被注册到 Agent 中后,模型会根据这些描述自主决定在当前上下文中是否需要调用它们。在并行分发工作流中,我们不会让模型来决定调用哪些工具,而是在工作流层面强制三个工具全部执行,但保留这个完整的工具定义仍然有价值,它允许用户在交互式场景中将这三个工具直接交给一个 Agent,让模型根据用户的具体需求灵活选择只做安全检查或只做静态分析。

LangGraph 并行审查工作流:Send API 驱动的三路并发

将三个审查工具串联执行虽然简单,但会浪费大量的等待时间,安全扫描等待静态分析完成,风格审查又等待安全扫描完成,端到端的延迟是三者耗时之和。在 LangGraph 的 Graph API 中,实现并行的核心机制是 Send API,它的设计灵感来源于 Google Pregel 系统中的消息传递模型。当一个节点返回一个 Send 对象列表时,每个 Send 对象都携带了目标节点名称和一份专属的状态数据,LangGraph 的运行时会在同一个 super-step 中将这些 Send 并发调度到各自的目标节点上执行,所有目标节点执行完毕后状态被合并,然后进入下一个 super-step。这种 fan-out → fan-in 模式天然适合代码审查场景,因为我们预先知道需要检查哪些维度,且每个维度之间完全独立。

在定义工作流之前,首先要定义状态结构。LangGraph 中使用 TypedDict 描述状态的 schema,每个字段可以指定一个 reducer 函数来控制新值如何与旧值合并。对于字符串类型的审查结果字段,我们不希望在多次更新中丢失之前的数据,因此需要谨慎选择 reducer。在实际使用中,由于静态分析、安全检查和风格审查各自写入不同的字段(static_resultsecurity_resultstyle_report),不存在两个节点争抢同一个字段的情况,使用默认的覆盖 reducer 即可。

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from langchain_core.messages import AnyMessage


class ReviewState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]
    code: str
    static_result: str
    security_result: str
    style_report: str
    final_report: str

ReviewState 中定义了六个字段。messages 字段使用 add_messages 作为 reducer,这是 LangGraph 为消息列表预置的归并函数,它不仅会将新消息追加到现有列表末尾,还会根据消息 ID 去重和更新已有消息,确保在并行节点同时返回消息时不会产生重复或冲突。code 字段存储用户提交的待审查代码,在分发节点中被读取后分发给三个审查节点。static_resultsecurity_resultstyle_report 分别接收三个审查节点的输出。最后 final_report 存储汇总生成的完整审查报告。

接下来定义三个审查节点的入口函数。这些函数并不是直接调用前面定义的 @tool 函数,而是使用 LLM 对代码进行审查分析。两种方式都是可行的,直接调用工具函数可以绕过模型推理环节,适用于规则明确、不需要语义理解的检查(如行长度统计),而让 LLM 执行审查则可以挖掘那些正则无法覆盖的问题,例如不合理的架构设计、潜在的性能瓶颈和不符合最佳实践的用法。这里采用的方法是每个审查节点内部使用 LLM 进行专项分析,同时工具函数中核心的正则匹配逻辑可以按需集成进来,实现规则引擎与大模型的双重把关。

from langchain.messages import HumanMessage, SystemMessage


def static_analysis_node(state: ReviewState) -> dict:
    """静态分析节点:对大模型发送代码,让它从结构和复杂度角度分析"""
    print("[静态分析] 开始分析...")
    response = llm.invoke(
        [
            SystemMessage(
                content=(
                    "你是一位资深 Python 代码结构分析师。请分析以下代码的结构质量。"
                    "关注:函数和类的组织方式、圈复杂度高的函数、过长的函数体、"
                    "嵌套层次过深的控制流、重复代码迹象、以及缺少文档字符串的公开接口。"
                    "请给出具体的行号引用和可操作的改进建议。"
                )
            ),
            HumanMessage(content=state["code"])
        ]
    )
    print("[静态分析] 完成")
    return {"static_result": response.content}


def security_scan_node(state: ReviewState) -> dict:
    """安全检查节点:对大模型发送代码,让它从安全角度进行审查"""
    print("[安全检查] 开始扫描...")
    response = llm.invoke(
        [
            SystemMessage(
                content=(
                    "你是一位资深应用安全专家。请审查以下 Python 代码的安全问题。"
                    "重点检测:SQL 注入、命令注入、跨站脚本(XSS)、路径遍历、"
                    "不安全的反序列化(pickle/yaml.load)、硬编码的敏感信息、"
                    "不安全的加密算法(MD5/SHA1 用于密码哈希)、缺少权限校验的操作。"
                    "请按严重程度排序,给出具体行号和修复方案。"
                )
            ),
            HumanMessage(content=state["code"])
        ]
    )
    print("[安全检查] 完成")
    return {"security_result": response.content}


def style_check_node(state: ReviewState) -> dict:
    """风格审查节点:对大模型发送代码,让它从可读性和风格角度进行审查"""
    print("[风格审查] 开始检查...")
    response = llm.invoke(
        [
            SystemMessage(
                content=(
                    "你是一位 Python 代码风格审查员。请审查以下代码的编码风格和可读性。"
                    "关注:PEP 8 合规性、命名规范(类用大驼峰、函数和变量用小写下划线)、"
                    "注释和文档字符串的质量与覆盖程度、魔法数字的使用、"
                    "过长的代码行(超过 120 字符)、不一致的缩进或引号风格。"
                    "请给出具体的行号引用和修改建议。"
                )
            ),
            HumanMessage(content=state["code"])
        ]
    )
    print("[风格审查] 完成")
    return {"style_report": response.content}

三个审查节点的结构对称而统一,每个节点都从 state["code"] 中读取同一份待审查代码,通过 SystemMessage 设定一个高度专注的角色提示词,将 LLM 的行为精准约束在对应的审查维度上。静态分析节点的系统提示词强调了结构质量、圈复杂度、函数体长度和嵌套层次等可量化的指标,安全检查节点的提示词则列出具体的漏洞类型和危险函数名称,风格审查节点要求模型关注命名、注释、魔法数字和格式一致性。这种"同一模型、不同角色"的分工方式是 LLM 应用中的一个典型设计模式,通过变换系统提示词中设定的专家身份和分析框架,同一个模型实例可以在不同的节点中扮演完全不同的审查者角色。

三个节点的返回值分别是 {"static_result": ...}{"security_result": ...}{"style_report": ...},每个节点只写回自己对应的状态字段,不触碰其他字段。LangGraph 的默认 reducer 会用新值直接覆盖旧值,由于三个节点写入的是不同的字段,不存在冲突,整个合并过程对开发者完全透明。

分发节点是整个并行机制的核心。它在读取用户代码后,不直接执行任何审查逻辑,而是返回三个 Send 对象,每个对象都指定了目标节点名称和该节点所需的专属状态。LangGraph 的运行时接收到这三个 Send 后,会在同一个 super-step 中将它们并行调度出去。

from langgraph.types import Send


def dispatch_analysis(state: ReviewState) -> dict:
    """分发节点:打印分发信息,实际路由在 conditional_edges 中处理"""
    print(f"[分发] 代码长度 {len(state['code'])} 字符,开始并行分发...")
    return {}

def route_to_analyses(state: ReviewState) -> list[Send]:
    """路由函数:通过 Send 将代码并行派发到三个审查节点"""
    code = state["code"]
    return [
        Send("static_analysis", {"code": code}),
        Send("security_scan", {"code": code}),
        Send("style_check", {"code": code}),
    ]

route_to_analyses 函数的返回值类型是 list[Send],这是 LangGraph 识别并行分发的关键标记。当函数返回一个 Send 列表时,LangGraph 不会像处理普通字典返回值那样尝试将结果合并到全局状态,而是将列表中的每个 Send 视为一个独立的调度指令。每个 Send 构造函数接收两个参数,第一个参数 "static_analysis"(或 "security_scan""style_check")是之前通过 add_node 注册的节点名称,第二个参数 {"code": code} 是一个部分状态字典,这个字典会在目标节点开始执行前被合并到该节点可见的状态视图中。这意味着三个审查节点各自都会看到 state["code"] 的值,但它们互相之间看不到彼此的中间输出,直到所有节点执行完毕并将结果写回全局状态后,下一阶段的报告汇总节点才能同时读到三份审查结果。

接下来实现报告汇总节点。这个节点在三个审查节点的输出全部就绪后运行,负责将分散的审查结果整合为一篇完整的、有优先级排序的审查报告。

REPORT_SYSTEM_PROMPT = """你是一位资深的代码审查主管。请你将以下三个维度的审查结果整合为一份完整、专业的代码审查报告。

报告应包含以下部分:
1. **总体评价**:一段简短的总结,概述代码的整体质量水平和最突出的问题。
2. **安全审查发现**(按严重程度降序):列出安全问题、风险等级和修复方案。
3. **结构分析发现**:列出代码结构、复杂度和组织方式上的问题与建议。
4. **风格审查发现**:列出命名、格式、注释等风格问题与改进方案。
5. **改进优先级建议**:按紧急程度给出修复优先级排序。

请使用清晰的 Markdown 格式,问题引用要包含具体的行号或函数名。语言使用中文。
"""


def generate_report_node(state: ReviewState) -> dict:
    """报告汇总节点:接收三个审查结果,生成综合报告"""
    print("[报告生成] 汇总三个审查结果...")
    context = (
        f"## 静态分析结果\n\n{state['static_result']}\n\n"
        f"## 安全检查结果\n\n{state['security_result']}\n\n"
        f"## 风格审查结果\n\n{state['style_report']}"
    )

    response = llm.invoke(
        [
            SystemMessage(
                content=REPORT_SYSTEM_PROMPT
            ),
            HumanMessage(
                content=(
                    f"请基于以下审查发现,生成一份综合代码审查报告:\n\n{context}"
                )
            ),
        ]
    )
    print("[报告生成] 完成")
    return {"final_report": response.content}

generate_report_node 是工作流的最后一个业务节点,它在三个并行审查节点全部完成后执行。函数内部将 static_resultsecurity_resultstyle_report 三个字段的值拼接为一段完整的上下文,然后以"代码审查主管"的角色提示词要求 LLM 对三份独立的审查结果做综合处理。LLM 在这里承担的不是审查任务,而是信息整合与优先级判断任务,它需要从三份报告各自列举的数十项发现中识别出哪些是必须立即修复的高危安全问题,哪些是影响长期可维护性的结构性问题,哪些是锦上添花的风格建议,然后按照合理的优先级重新排序。系统提示词中明确规定了报告的五段式结构(总体评价、安全发现、结构发现、风格发现、优先级建议),这让每次生成的报告格式保持稳定,便于在 CI/CD 系统中做自动化解析或嵌入 Pull Request 评论。

现在将所有组件组装成一个完整的 StateGraph。add_node 注册四个节点,add_conditional_edgesdispatch 节点连接到 route_to_analyses 函数。注意这里的用法,当一个条件边函数返回 Send 列表时,LangGraph 会将其解释为并行调度指令,而不是普通的节点名称路由。add_edge 将三个审查节点各自连接到 generate_report,最后从 generate_report 连接到 END 终止。

from langgraph.graph import StateGraph, START, END
from IPython.display import Image, display


# 构建工作流图
review_builder = StateGraph(ReviewState)

review_builder.add_node("dispatch",dispatch_analysis)
review_builder.add_node("static_analysis",static_analysis_node)
review_builder.add_node("security_scan",security_scan_node)
review_builder.add_node("style_check",style_check_node)
review_builder.add_node("generate_report",generate_report_node)

# 入口边 START -> dispatch
review_builder.add_edge(START,"dispatch")

# 条件边:dispatch 通过 Send 列表 → 三路并行
review_builder.add_conditional_edges(
    "dispatch",
    route_to_analyses,
)

# 三个审查节点完成后 → 报告汇总
review_builder.add_edge("static_analysis", "generate_report")
review_builder.add_edge("security_scan", "generate_report")
review_builder.add_edge("style_check", "generate_report")

# 报告汇总完成 → 结束
review_builder.add_edge("generate_report", END)

# 编译工作流
review_app = review_builder.compile()
display(Image(review_app.get_graph().draw_mermaid_png()))

add_conditional_edges 在这里扮演了一个关键角色。它的第一个参数 "dispatch" 指定了触发条件判断的源节点,第二个参数 route_to_analyses 是一个路由函数,第三个参数 path_map 是一个字符串列表,声明了路由函数可能返回的所有目标节点名称。当 route_to_analyses 返回 [Send("static_analysis", ...), Send("security_scan", ...), Send("style_check", ...)] 时,LangGraph 不是去匹配 path_map 中的某个值进行单一路由,而是检测到返回值是一个 Send 列表后自动切换到并行调度模式,将三个 Send 分别投递到对应的节点。path_map 在这里的作用是声明所有可能的目标节点,帮助 LangGraph 在编译时验证图结构的完整性和生成正确的可视化图表。

三个审查节点都有一条指向 generate_report 的边,意思是 generate_report 节点有三个入边。在 LangGraph 的 Pregel 模型中,当一个节点有多个入边时,只有当所有入边的上游节点都在当前 super-step 中完成了执行,该节点才会在下一个 super-step 中被激活。这正是 fan-in 语义的体现,无论三个审查节点各自的执行耗时如何(网络延迟、模型推理时间差异等),报告汇总节点耐心等待最慢的那个完成,然后一次性拿到全部三份结果开始整合。这种机制保证了汇总报告的完整性,不会出现只有安全检查结果而静态分析结果还为空就开始生成报告的情况。

完整运行示例与结果验证

工作流编译完成后,我们准备一段包含多种问题的示例代码作为审查输入。这段代码精心构造了函数职责过重、SQL 注入风险、硬编码密钥、裸 except、PEP 8 风格违规和危险函数调用等多类问题,是一份合格的"反面教材"。

from langchain_core.messages import HumanMessage


sample_code = '''
import sqlite3
import pickle

password="admin123"

class user_manager:
    def getUser(self, id):
        conn = sqlite3.connect("users.db")
        cursor = conn.cursor()
        cursor.execute("SELECT * FROM users WHERE id=" + id)
        return cursor.fetchone()

    def AdminTask(self, data):
        try:
            result = eval(data)
            return result
        except:
            pass

def do_stuff(x,y,z,a,b,c):
    v = x+y+z+a+b+c
    if v>10:
      if v>20:
        if v>30:
          if v>40:
            if v>50:
              print("big number")
    return v

def loadUserPrefs(path):
    with open(path,"rb") as f:
        return pickle.load(f)
'''

result = review_app.invoke(
    {
        "messages": [HumanMessage(content="请审查这段 Python 代码")],
        "code": sample_code,
    }
)

print(result["final_report"])

review_app.invoke 接收一个包含 messagescode 两个字段的状态字典。当这个调用开始时,LangGraph 从 START 边流向 dispatch 节点,dispatch_analysis 函数执行并返回三个 Send 对象,运行时在同一 super-step 中并行启动三个审查节点。在控制台中,三个审查节点的 print 语句会交织输出,这正是并行执行的直观证据,如果它们是串行执行的,你会先看到静态分析的开始和结束,再看到安全检查的开始和结束,最后看到风格审查的开始和结束。但在并行模式下,三条"开始"消息几乎同时出现,三条"完成"消息也在相近的时间点到达。三个节点全部完成后,generate_report 节点被触发,整合所有结果并输出最终的 Markdown 审查报告。

并行执行的时序可以通过以下方式进一步验证:在每个节点的 print 语句中加入时间戳,观察三个节点的启动时间是否集中在同一个毫秒级窗口内。

from datetime import datetime

# 在每个节点的函数体中加入:
# print(f"[节点名] 开始 @ {datetime.now().strftime('%H:%M:%S.%f')[:-3]}")

执行结果会清楚展示三个审查节点的启动时间完全相同(或仅相差几毫秒),而报告汇总节点的时间戳则明显晚于三个审查节点中最晚完成的那个。这种时序特征正是 LangGraph Send API 并行调度机制的直观验证。

sample_code 中精心布置了七个审查点。password="admin123" 是典型的硬编码密钥,安全检查节点和高危级别的正则扫描都会将其标记出来。cursor.execute("SELECT * FROM users WHERE id=" + id) 通过字符串拼接构造 SQL 查询,是教科书级别的 SQL 注入漏洞。eval(data) 将用户输入直接传入 eval 执行,是极度危险的操作,攻击者可以通过它执行任意 Python 代码。except: pass 是裸 except 且什么也不做的反模式,静态分析节点的 AST 遍历会捕获它。class user_manager 的类名使用下划线而非大驼峰,getUser 方法名混用了大小写,风格审查节点会指出这两个命名违规。do_stuff 函数接受了 6 个参数且嵌套了五层 if,圈复杂度极高,静态分析节点会标记为需要重构。pickle.load(f) 对未验证来源的文件进行反序列化,安全检查节点会将其标记为不安全的反序列化操作。这七个问题覆盖了三个审查维度和不同的严重等级,为报告汇总节点提供了丰富的素材来展示优先级排序能力。

result["final_report"] 输出的是一篇遵循五段式结构的 Markdown 审查报告,可以直接复制到 Pull Request 的评论框中,也可以保存为 .md 文件作为代码审查的正式记录。在人机协作的审查流程中,人工审查者可以先阅读这份自动生成的报告,快速定位那些已经被机器发现的问题,然后集中精力审查逻辑正确性、业务匹配度和架构设计等需要人类判断力的层面。这正是代码审查助手的设计目标:不是替代人,而是让人从机械性的检查工作中解放出来,专注于更有价值的思考。

练习任务

  • 实现代码审查 Agent
  • 使用 LangGraph 并行执行多个检查
  • 生成格式化的审查报告

考核点 ✅

  1. 审查 Agent 运行:提交代码审查 Agent 代码,能对给定代码文件输出审查结果
  2. 并行执行:提交 LangGraph 并行执行 3 个检查工具的代码,展示并发执行日志
  3. 报告生成:提交格式化的审查报告(HTML/Markdown/PDF 任选)
  4. 工具集成:口头解释如何将静态分析工具(如 pylint/flake8)封装为 LangChain tool
posted @ 2026-07-05 00:00  喵叔哟  阅读(2)  评论(0)    收藏  举报