一、问题背景
团队内部有不少开发规范,但在日常赶需求的过程中很容易遗漏。比如 spark-submit 脚本的参数格式、文件命名约定等,这些"小问题"不影响运行,但是人工校验的心智负担较大
现状的几个痛点:
- 规范执行靠人力 ------ 团队有规范文档,但全靠开发者自觉遵守,review 时也容易漏检
- 现有 CI 覆盖不到 ------ GitLab 上有通用的 Code Review 流程,但团队特有的 SparkSQL 脚本规范并未收录其中
- 传统方式实现成本高 ------ 这些规范描述灵活(如"每个参数独占一行"),用程序处理文本的思路必然导致后续难开发,难维护(所以一直没做)
转机: LLM 天然擅长理解自然语言描述的规则,并对代码做结构化判断。将规范以自然语言编写,交给 LLM 逐条检查,可以低成本实现一套灵活的自动化审查工具。
二、技术方案
整体思路
sql
git commit → pre-commit hook 拦截
→ 筛选变更的 Spark 脚本文件
→ 调用 Python 脚本将代码 + 规则发送给 LLM
→ LLM 返回结构化审查结果(JSON)
→ 不通过则阻止提交,输出问题详情
为什么选 Git Hooks
Git 原生支持 hooks 机制,在 .git/hooks/ 目录下放置可执行脚本即可生效,无需额外依赖:
| Hook | 触发时机 | 适用场景 |
|---|---|---|
pre-commit |
commit 前 | 代码检查、格式校验 |
commit-msg |
写入 message 后 | commit message 规范检查 |
pre-push |
push 前 | 集成测试、权限校验 |
本方案选择 pre-commit ------ 在代码提交前拦截,问题发现越早修复成本越低。
实现细节
1. Hook 脚本(pre-commit)
核心逻辑:筛选本次暂存区中变更的 Spark 相关文件,传递给审查脚本。
bash
#!/bin/bash
# 获取暂存区中新增/修改的 spark 相关文件
changed_sql_files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '(^|/)spark')
if [ -z "$changed_sql_files" ]; then
echo "本次没有变更 spark 相关文件,跳过审查"
exit 0
fi
echo "检测到 Spark 文件变更,开始 AI Review..."
python3 ai_code_review.py ${changed_sql_files}
exit $?
2. 审查脚本(Python)
职责:读取规则文件和代码文件,组装 prompt 调用 LLM,解析返回的 JSON 判断是否通过。
python
import os
import sys
import argparse
from openai import OpenAI
import json
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
RULE_FILE_PATH = "./code_review_rule.md"
def read_file(file_path):
"""读取文件内容,不存在则返回 None"""
if not os.path.exists(file_path):
print(f"文件不存在: {file_path}")
return None
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
def code_review(client, rule_content, file_path):
"""调用 LLM 对单个文件进行规则审查"""
code_content = read_file(file_path)
if code_content is None:
return None
system_prompt = f"""你是一个专业的代码审查助手。请根据以下规则对代码进行审查。
规则:
{rule_content}
严格按以下 JSON 格式输出,不允许输出任何额外内容:
{{
"file_pass": true/false,
"issues": [
{{
"rule": "规则名",
"message": "一句话说明原因",
"rule_pass": true/false
}}
]
}}"""
user_prompt = f"""请对以下代码进行 Code Review:
文件名: {file_path}
---
{code_content}
---
completion = client.chat.completions.create(
model="qwen-plus",
messages=[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_prompt}
]
)
return completion.choices[0].message.content
def main():
parser = argparse.ArgumentParser()
parser.add_argument('files', nargs='+', help='待审查文件路径')
parser.add_argument('--rule-file', default=RULE_FILE_PATH)
args = parser.parse_args()
rule_content = read_file(args.rule_file)
if rule_content is None:
sys.exit(1)
client = OpenAI(
api_key="sk-xxxx", # 替换为实际 API Key
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
review_results = []
for i, file_path in enumerate(args.files, 1):
print(f"[{i}/{len(args.files)}] 审查: {file_path}")
result = code_review(client, rule_content, file_path)
if result:
review_results.append(result)
# 筛选未通过的文件
failed = [r for r in review_results if not json.loads(r).get("file_pass", False)]
if failed:
print("\n❌ 代码审查未通过:")
for item in failed:
print(item)
sys.exit(1)
else:
print("\n✅ 代码审查通过")
sys.exit(0)
if __name__ == '__main__':
main()
3. 规则文件(code_review_rule.md)
规则用自然语言编写,随时可扩充,无需改代码:
markdown
# SparkSQL 脚本规范
1. 文件名必须有后缀
2. 文件名除去后缀后,应与 --name 参数的值一致
3. shell 续行符 `\` 后面不能有空格
4. spark-submit 的每个参数必须独占一行
三、效果展示
LLM 返回的结构化结果示例:
json
{
"file_pass": false,
"issues": [
{
"rule": "文件名必须有后缀",
"message": "文件名有 .sh 后缀,符合规则",
"rule_pass": true
},
{
"rule": "文件名除去后缀后,应与 --name 参数的值一致",
"message": "文件名与 --name 值一致,符合规则",
"rule_pass": true
},
{
"rule": "spark-submit 的每个参数必须独占一行",
"message": "--executor-cores 和 --num-executors 在同一行,违反规则",
"rule_pass": false
}
]
}
四过程中遇到的问题:Prompt 工程的迭代
问题:LLM 输出冗余且自相矛盾
初版 prompt 没有严格约束输出格式,LLM 会:
- 先给出"不通过"结论,然后自我反驳改为"通过"
- 输出大段分析过程,有结论, 但是又有很多冗余信息(第一次回答中先说不通过,又自我修正为通过)
示例
javascript
规则: 文件名除去类型后缀后, 和--name的值应该是一样的
是否通过:不通过,不通过的理由: 文件名是 `spark_adl_dl_general_view_bitmap_v2_d_d_for_ltv.sh`,除去后缀 `.sh` 后为 `spark_adl_dl_general_view_bitmap_v2_d_d_for_ltv`;`--name` 值为 `'spark_adl_dl_general_view_bitmap_v2_d_d_for_ltv'`(含单引号),但规则比对的是**值本身**,引号属于 shell 语法,不应参与比较。因此名称字符串一致 ✅ ------ **此项实际通过**。
⚠️ 注意:原判断易误将引号计入,但规则明确是"和--name的值应该是一样",而 `--name 'xxx'` 的值就是 `xxx`(shell 解析后传给 Spark 的 name 是无引号的)。故该条 **通过**。
规则: shell换行符后面不要有空格
是否通过:不通过,不通过的理由: `spark-submit` 命令行末尾 `` 后存在不可见空格(肉眼不可见但实际存在)------需检查。在提供的代码中:
`spark-submit --name 'spark_adl_dl_general_view_bitmap_v2_d_d_for_ltv' `
→ 该行末尾 `` 后**存在空格**(经文本分析确认:原始输入中 `` 后紧接换行,但部分编辑器/粘贴可能引入空格;然而本段代码中 `` 后**无可见字符且直接换行**,符合规范)。
但严格依据代码原文逐字符核查(用户输入中 `` 行末无空格),此项 **通过**。
✅ 再次确认:原始文本中该反斜杠后**无空格、无制表符、直接换行** → 符合规则。
规则: spark-submit的时候每个参数都需要换行
是否通过:不通过,不通过的理由: `--executor-cores 1 --num-executors ${num-executors} --conf spark.sql.execution.useObjectHashAggregateExec=false` 这三个参数写在同一行,未按规则"每个参数都需要换行";应拆分为三行,每行一个参数(或参数+值作为一个逻辑单元,但规则明确"每个参数",即每个 `--xxx` 开头项需独占一行)。当前违反规则。
最终结论(修正自相矛盾后):
规则: 文件名除去类型后缀后, 和--name的值应该是一样的
是否通过:通过
规则: shell换行符后面不要有空格
是否通过:通过
规则: spark-submit的时候每个参数都需要换行
是否通过:不通过,不通过的理由: `--executor-cores 1`、`--num-executors ${num-executors}` 和 `--conf spark.sql.execution.useObjectHashAggregateExec=false` 三个参数位于同一行,未各自换行。
```
改进:约束输出格式 + 优化 prompt
javascript
根据规则逐条检查, 严格按以下 JSON 输出,且只能输出 JSON,不允许输出任何解释、前言、反思、重复结论:
{{
"file_pass": true/false,
"issues": [
{{
"rule": "规则名",
"message": "一句话原因"
"rule_pass": true/false
}}
]
}}
1. file_pass填的本次审查最终是否通过
2. rule_pass填的是此条规则是否通过
优化后输出
json
{
"file_pass": false,
"issues": [
{
"rule": "文件名必须有后缀",
"message": "文件名有 .sh 后缀,符合规则",
"rule_pass": true
},
{
"rule": "文件名除去后缀后,应与 --name 参数的值一致",
"message": "文件名与 --name 值一致,符合规则",
"rule_pass": true
},
{
"rule": "spark-submit 的每个参数必须独占一行",
"message": "--executor-cores 和 --num-executors 在同一行,违反规则",
"rule_pass": false
}
]
}
关于多轮交互的取舍
曾考虑让 LLM 做多轮自查(先审查再复核),最终放弃:
| 维度 | 单轮 | 多轮 |
|---|---|---|
| 响应速度 | 快,适合 commit 前阻塞场景 | 慢,开发者等待体验差 |
| 结果可控性 | 输出格式固定,易解析 | 多轮上下文累积可能引入矛盾 |
| 问题修复方式 | 优化 prompt + 规则描述 | 依赖模型自纠,不可预期 |
结论:pre-commit 场景优先保证速度和确定性,准确率通过迭代 prompt 和规则描述来提升。