模块三-AI编码规范体系 | 第20讲:CodeSentinel 规范解析引擎 - 用 LLM 理解和执行 AGENTS.md 规则
本讲目标:理解 AGENTS.md 从「给人看」到「给机器执行」的鸿沟;掌握规则解析、确定性校验与 LLM 主观评估的混合架构;在 CodeSentinel 中落地可扩展的规范解析与执行引擎。
开场:当规范写在 Markdown 里,机器却需要「可判定」
在 CodeSentinel 这类 AI 驱动的代码审核与架构治理平台中,AGENTS.md(或团队自定义的 CLAUDE.md、CONTRIBUTING.md 中的规范章节)往往承担三重角色:给人类工程师阅读、给 IDE/Agent 作为系统提示的上下文、以及——理想情况下——作为可执行的治理契约。但现实很骨感:Markdown 是自然语言与半结构化标题的混合体,同一条规则在不同人眼里可能有不同解释;而 CI 里的静态检查、import 黑名单、架构边界检测,却要求输出是确定性的 pass/fail 或可复现的 evidence。
本讲要解决的核心矛盾是:AGENTS.md 对人类友好,对程序不友好。CodeSentinel 不能把「整份 Markdown 丢给大模型让它随便说说」当作工程方案——成本高、漂移大、难以审计。只有把规则拆成条目并明确执行通道,平台才能在规模化团队里稳定运行。更合理的路线是「分层」:把规则拆成可结构化提取的条目,再按条目类型路由到**规则引擎(确定性)**或 LLM 评估器(主观/模式类),最后用编排器(Orchestrator)统一产出可追溯的合规结果。
你会带走三样东西:一套 AgentsmdParser 的解析策略(章节、列表、标签化元数据);一个 RuleEngine(正则 + AST 的确定性检查器);一个 LLMRuleEvaluator(LangChain 封装 + 强约束 JSON 输出)。它们组合成的混合流水线,是模块三后续「规范版本管理」「团队合规看板」的地基。掌握这套拆分方法后,你可以逐步把更多组织规则从文档推进到自动化执行。下面先用一张图把「从文档到判定」的全链路立起来,再深入原理与代码。
全局视角:规则解析与混合评估流水线(Mermaid)
flowchart LR
subgraph Input["输入"]
A["AGENTS.md 原文"]
C["待审代码快照\n(单文件或多文件)"]
end
subgraph Parse["解析层 AgentsmdParser"]
P1["Markdown 分节"]
P2["规则条目抽取\n(标题/列表/代码块)"]
P3["规则分类 tagging\nstrict | heuristic | llm"]
end
subgraph Route["路由 Orchestrator"]
R1{"规则类型?"}
end
subgraph Det["确定性 RuleEngine"]
D1["Regex 检查器"]
D2["AST import 检查器"]
D3["自定义 Checker 插件"]
end
subgraph LLM["主观 LLMRuleEvaluator"]
L1["LangChain Prompt"]
L2["JSON Schema 约束输出"]
L3["证据引用\n(文件/行号/片段)"]
end
subgraph Out["输出 ComplianceReport"]
O1["逐项结果 + severity"]
O2["可审计 reasoning(仅 LLM 项)"]
end
A --> P1 --> P2 --> P3 --> R1
C --> R1
R1 -->|strict/heuristic| D1 & D2 & D3
R1 -->|llm| L1 --> L2 --> L3
D1 & D2 & D3 --> O1
L3 --> O1 --> O2
这张图强调两个工程事实:第一,解析与执行必须解耦——否则每次改提示词都会牵动解析器;第二,路由是稳定接口,未来可以接入更多 Checker(例如 Semgrep、Ruff、自定义 DSL),而不破坏上层 API。
核心原理:为什么必须「混合」,而不是「全靠 LLM」或「全靠规则」
1. 规则的「可判定性光谱」
可以把规范条目想象成一条光谱:
- 左端:强可判定。例如「禁止从
app.infrastructure直接 importapp.presentation」「禁止使用eval」「函数圈复杂度不超过 15」。这类规则要么能写成 AST 查询,要么能写成正则/字节码模式,执行结果在相同输入下应完全一致。 - 右端:弱可判定/主观。例如「遵循整洁架构依赖方向」「错误处理要体现领域语义」「API 设计要保持幂等等语义清晰」。这类规则需要理解上下文、权衡取舍,LLM 作为「软检查器」更合适——但必须要求它输出结构化结果,并把「理由」当作辅助证据而非最终法律依据(最终仍可人工复核)。
CodeSentinel 的立场不是二选一,而是先左后右:左端能挡住 70%~90% 的低级违规与架构穿透;右端用于捕获「规则写得很对但代码很巧」的灰色地带。
2. 规则解析:从 Markdown 到结构化 RuleRecord
AGENTS.md 常见写法包括:##/### 章节、无序列表、有序列表、反引号包裹的文件路径、以及偶尔出现的「标签」如 severity: high。解析器的职责不是做 NLP 全文理解,而是做可靠的浅层结构化:
- 分节:按 ATX 标题切分,继承父章节路径作为
rule.namespace(例如架构约束/依赖方向)。 - 条目化:每个列表项或独立段落成为一条
rule.text。 - 分类启发式:
- 出现「禁止 import」「不得从…import」「never import」→
kind=import_ban→ 走 AST。 - 出现「必须匹配正则」「文件名必须符合」→
kind=regex。 - 出现「保持」「体现」「遵循…模式」且没有可抽取的机器模式 →
kind=llm_subjective。
- 出现「禁止 import」「不得从…import」「never import」→
这类启发式可以逐步演进为「显式 front matter」:例如在 Markdown 里用 HTML 注释或 YAML 块写 <!-- rule:kind=import_ban -->,解析器优先读取显式元数据——这也是生产环境降低误判的关键。
3. 确定性引擎:Regex + AST 的分工
- Regex适合文件级、文本级、简单禁令(例如禁止
TODO: hack出现在production路径)。缺点是误报/漏报与上下文相关,因此要配合路径过滤与豁免列表。 - AST适合 Python 的
import与模块依赖分析。做法是ast.parse后遍历Import/ImportFrom,解析module与level,映射到可比较的模块前缀。AST 方案对格式化不敏感,比纯文本匹配稳定得多。
4. LLM 评估:提示词工程的本质是「把裁判规则写清楚」
LLM 侧常见失败模式包括:输出散文不可解析、编造行号、把规范条目过度泛化。对策是:
- 系统提示明确角色:你是合规审查员,只能依据给定规则与代码证据判断。
- 用户提示包含:规则原文、代码片段(必要时附文件路径)、输出 JSON 的字段定义。
- 后处理:
json.loads失败则重试或标记为error;对line做范围校验。
5. 混合编排:Orchestrator 的合并策略
Orchestrator 对每条规则执行:
- 若
kind属于确定性集合 → 调用RuleEngine,生成passed/violated/skipped。 - 若
kind为llm_subjective→ 调用LLMRuleEvaluator。 - 合并结果时,确定性违规与 LLM 高风险并存时,以确定性结果优先展示(更可审计),LLM 结果作为补充说明。
6. 从「提示词」到「规则即数据」:CodeSentinel 的扩展方式
很多团队第一次做 AI 审核,会把 AGENTS.md 直接拼进 LLM 的 system prompt。短期看似省事,长期会出现三类问题:第一,规则越长,提示词越臃肿,模型注意力被稀释,关键约束反而更容易被忽略;第二,规则变更无法与审计轨迹绑定,你很难回答「当时到底依据哪一条规范判了不通过」;第三,规则无法被其他子系统消费,例如无法在 IDE 侧做轻量提示、无法在 CI 侧做快速拦截。
CodeSentinel 更推荐把流程拆成「规则数据化 → 分类路由 → 多通道执行 → 结构化回写」。所谓规则数据化,并不是要求产品同学去写 JSON Schema,而是要求解析层输出稳定的 RuleRecord:包含稳定 rule_id、可读 namespace、原始 text、推断或可配置的 kind、以及可选 meta。一旦规则成为数据,你就可以对它做索引、做版本 diff、做灰度发布,甚至把某些规则标记为「仅建议」或「阻断合并」。
7. 解析器的工程细节:为什么要避免「过度聪明」
解析器最容易犯的错误是试图用 NLP 去理解所有句子。工程上更可控的策略是「显式优先、启发式兜底」。显式优先意味着:团队可以在 Markdown 里用统一约定标注规则类型,例如在每个列表项前增加机器可读标签,或在章节级增加 YAML 配置块;启发式兜底意味着:对常见句式做模式匹配,把不确定的条目标记为 UNKNOWN 或默认走 LLM,而不是瞎猜成 import_ban 造成确定性误杀。
另一个细节是命名空间继承。规则往往嵌套在多层标题下,子标题下的列表项应当继承父路径,这样合规报告才能以「架构/依赖」「安全/密钥」「性能/并发」这类路径聚合展示。对大型单体仓库,命名空间还能帮助你在 UI 上折叠展示,减少评审噪音。
8. AST 规则的边界:什么时候必须引入外部工具
AST 能解决 Python import 与部分语法结构问题,但解决不了跨语言、跨文件引用、以及构建系统层面的模块别名。例如某些项目用 src 布局配合动态导入,或存在 conftest.py、插件机制导致的间接依赖。此时 CodeSentinel 应把「依赖真相」交给更专业的工具链(Ruff、mypy、import-linter、Semgrep),而 AST 检查器只负责低成本、高价值的内置能力,或通过插件接口调用外部执行器并把结果标准化为 RuleVerdict。
9. LLM 评估的「可复核」设计:让理由服务于人,而不是绑架人
LLM 输出的 reasoning 在工程上应当被视为解释性材料,而不是法律证据。可复核设计的关键是 citations:尽量要求模型引用它确实见过的代码片段,并在服务端验证片段是否存在于对应文件行附近。若无法验证,就把该条目标记为 low_confidence,并在看板上提示人工复核。对高风险仓库(金融、合规),还可以要求「LLM 仅输出候选问题列表」,最终由人工或二次策略确认。
10. 与本项目 codesentinel-clean-lab 的衔接建议
你在模块二已经具备审核聚合与 finding 记录能力。本讲的 Orchestrator 输出 ComplianceReport 后,推荐映射为 AddFindingCommand:确定性违规映射为 high/critical,正则类中等映射为 medium,LLM 且 confidence 低映射为 low。这样同一条 PR 审核链路里,规范执行与模型建议共用一套严重级别体系,避免研发在多个系统里看两套口径。
混合评估架构:组件边界与数据流(Mermaid)
flowchart TB
subgraph Domain["领域对象"]
RR["RuleRecord\nid, namespace, text, kind, meta"]
CR["CheckContext\nfiles: path->source"]
RV["RuleVerdict\nstatus, severity, evidence"]
end
subgraph Parser["AgentsmdParser"]
MD["parse_agents_md() -> list[RuleRecord]"]
end
subgraph Engine["RuleEngine"]
Cfg["EngineConfig\nbanned_imports, patterns"]
Run["evaluate(rule, ctx) -> RuleVerdict"]
end
subgraph Eval["LLMRuleEvaluator"]
LC["LangChain Runnable"]
Sch["JSON: compliant, confidence, reasoning"]
end
subgraph Orch["ComplianceOrchestrator"]
Mer["merge_verdicts()"]
end
MD --> RR
RR --> Orch
CR --> Orch
Orch -->|deterministic| Engine --> RV
Orch -->|llm| Eval --> RV
RV --> Mer
代码实战:完整 Python 实现(可直接运行)
下面示例使用标准库完成解析与 AST;LangChain 侧使用 langchain_core 自带的 FakeListChatModel,无需真实 API Key 也能跑通链路。若你已配置 OpenAI,可把 build_evaluator() 中的 factory 切换为真实模型。
依赖(建议):
langchain-core>=0.2.0
将以下代码保存为 codesentinel_rules_demo.py 并执行:python codesentinel_rules_demo.py。
from __future__ import annotations
import ast
import json
import re
import textwrap
import uuid
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple
# -----------------------------
# 数据模型
# -----------------------------
class RuleKind(str, Enum):
IMPORT_BAN = "import_ban"
REGEX = "regex"
LLM_SUBJECTIVE = "llm_subjective"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class RuleRecord:
rule_id: str
namespace: str
text: str
kind: RuleKind
meta: Dict[str, str] = field(default_factory=dict)
@dataclass(frozen=True)
class FileSnapshot:
path: str
content: str
@dataclass
class RuleVerdict:
rule_id: str
status: str # passed | violated | skipped | error
severity: str
channel: str # deterministic | llm
message: str
evidence: Dict[str, object] = field(default_factory=dict)
@dataclass
class ComplianceReport:
items: List[RuleVerdict]
# -----------------------------
# AgentsmdParser:从 AGENTS.md 抽取规则
# -----------------------------
class AgentsmdParser:
"""浅层 Markdown 解析:按标题分节,将列表项规则化。"""
_heading_re = re.compile(r"^(#{1,6})\s+(.*)$")
_list_item_re = re.compile(r"^\s*[-*+]\s+(.*)$")
_import_ban_re = re.compile(
r"(禁止|不得|不要|never)\s+(?:从\s*)?[`\"]?([\w.]+)[`\"]?\s+(?:import|导入)",
re.IGNORECASE,
)
_from_import_ban_re = re.compile(
r"(禁止|不得|不要)\s+from\s+[`\"]?([\w.]+)[`\"]?\s+import",
re.IGNORECASE,
)
def parse_agents_md(self, text: str) -> List[RuleRecord]:
lines = text.splitlines()
headings: List[Tuple[int, str]] = []
sections: Dict[str, List[str]] = {}
current = "ROOT"
sections[current] = []
for line in lines:
m = self._heading_re.match(line)
if m:
level = len(m.group(1))
title = m.group(2).strip()
headings.append((level, title))
current = " / ".join(t for _, t in headings)
sections.setdefault(current, [])
continue
sections[current].append(line)
rules: List[RuleRecord] = []
for namespace, buf in sections.items():
rules.extend(self._lines_to_rules(namespace, buf))
return rules
def _lines_to_rules(self, namespace: str, lines: List[str]) -> List[RuleRecord]:
out: List[RuleRecord] = []
paragraph: List[str] = []
def flush_paragraph() -> None:
nonlocal paragraph
if not paragraph:
return
text = "\n".join(paragraph).strip()
if text:
out.append(self._make_rule(namespace, text))
paragraph = []
for line in lines:
m = self._list_item_re.match(line)
if m:
flush_paragraph()
out.append(self._make_rule(namespace, m.group(1).strip()))
else:
if line.strip() == "":
flush_paragraph()
else:
paragraph.append(line)
flush_paragraph()
return out
def _make_rule(self, namespace: str, text: str) -> RuleRecord:
rid = str(uuid.uuid4())
kind, meta = self._classify(text)
return RuleRecord(rule_id=rid, namespace=namespace, text=text, kind=kind, meta=meta)
def _classify(self, text: str) -> Tuple[RuleKind, Dict[str, str]]:
if "regex:" in text or "正则" in text:
# 示例:规则文本内嵌 regex:foo.*
m = re.search(r"regex:\s*(\S+)", text)
if m:
return RuleKind.REGEX, {"pattern": m.group(1)}
m1 = self._import_ban_re.search(text)
if m1:
return RuleKind.IMPORT_BAN, {"banned_prefix": m1.group(2)}
m2 = self._from_import_ban_re.search(text)
if m2:
return RuleKind.IMPORT_BAN, {"banned_prefix": m2.group(2)}
if any(k in text for k in ("整洁架构", "Clean Architecture", "遵循", "保持", "体现")):
return RuleKind.LLM_SUBJECTIVE, {}
return RuleKind.UNKNOWN, {}
# -----------------------------
# RuleEngine:确定性检查
# -----------------------------
@dataclass
class EngineConfig:
extra_banned_prefixes: Tuple[str, ...] = ()
regex_rules: Tuple[Tuple[str, str], ...] = () # (rule_id_glob, pattern)
class RuleEngine:
def __init__(self, config: Optional[EngineConfig] = None) -> None:
self._config = config or EngineConfig()
def evaluate(self, rule: RuleRecord, files: Sequence[FileSnapshot]) -> RuleVerdict:
if rule.kind == RuleKind.IMPORT_BAN:
banned = rule.meta.get("banned_prefix")
if not banned:
return self._skip(rule, "缺少 banned_prefix 元数据")
hits = self._scan_imports(banned, files)
if hits:
return RuleVerdict(
rule_id=rule.rule_id,
status="violated",
severity="high",
channel="deterministic",
message=f"发现违规 import 指向 banned 前缀: {banned}",
evidence={"hits": hits},
)
return RuleVerdict(
rule_id=rule.rule_id,
status="passed",
severity="info",
channel="deterministic",
message="未发现匹配 banned 前缀的 import",
evidence={},
)
if rule.kind == RuleKind.REGEX:
pattern = rule.meta.get("pattern")
if not pattern:
return self._skip(rule, "缺少 regex pattern")
rx = re.compile(pattern)
hits: List[Dict[str, object]] = []
for f in files:
for i, line in enumerate(f.content.splitlines(), start=1):
if rx.search(line):
hits.append({"path": f.path, "line": i, "text": line.strip()})
if hits:
return RuleVerdict(
rule_id=rule.rule_id,
status="violated",
severity="medium",
channel="deterministic",
message=f"正则命中: {pattern}",
evidence={"hits": hits[:50]},
)
return RuleVerdict(
rule_id=rule.rule_id,
status="passed",
severity="info",
channel="deterministic",
message="正则未命中",
evidence={},
)
if rule.kind == RuleKind.UNKNOWN:
return self._skip(rule, "未知规则类型,确定性引擎跳过")
return self._skip(rule, "该规则不由确定性引擎处理")
def _skip(self, rule: RuleRecord, reason: str) -> RuleVerdict:
return RuleVerdict(
rule_id=rule.rule_id,
status="skipped",
severity="info",
channel="deterministic",
message=reason,
evidence={},
)
def _scan_imports(self, banned_prefix: str, files: Sequence[FileSnapshot]) -> List[Dict[str, object]]:
hits: List[Dict[str, object]] = []
banned_prefix = banned_prefix.rstrip(".")
extra = tuple(p.rstrip(".") for p in self._config.extra_banned_prefixes)
for f in files:
if not f.path.endswith(".py"):
continue
try:
tree = ast.parse(f.content, filename=f.path)
except SyntaxError as exc:
hits.append({"path": f.path, "line": exc.lineno or 0, "detail": f"syntax_error: {exc.msg}"})
continue
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
mod = alias.name
if self._is_banned(mod, banned_prefix, extra):
hits.append({"path": f.path, "line": node.lineno, "import": f"import {mod}"})
elif isinstance(node, ast.ImportFrom):
base = node.module or ""
level = node.level or 0
if level > 0:
# 相对导入:保守起见不展开解析,仅记录
hits.append(
{
"path": f.path,
"line": node.lineno,
"import": f"from {'.'*level}{base} import ...",
"note": "relative_import_requires_resolver",
}
)
continue
if self._is_banned(base, banned_prefix, extra):
names = ", ".join(a.name for a in node.names)
hits.append({"path": f.path, "line": node.lineno, "import": f"from {base} import {names}"})
return hits
@staticmethod
def _is_banned(module: str, banned_prefix: str, extra: Tuple[str, ...]) -> bool:
module = module.split(".")[0] if module else ""
full = banned_prefix
candidates = (full,) + extra
for ban in candidates:
if module == ban or module.startswith(ban + "."):
return True
return False
# -----------------------------
# LLMRuleEvaluator:LangChain 主观规则评估(可用 Fake 模型)
# -----------------------------
class LLMRuleEvaluator:
def __init__(self, runnable) -> None:
self._runnable = runnable
def evaluate(self, rule: RuleRecord, files: Sequence[FileSnapshot]) -> RuleVerdict:
payload = {
"rule": {"id": rule.rule_id, "namespace": rule.namespace, "text": rule.text},
"files": [{"path": f.path, "content": f.content[:8000]} for f in files],
}
prompt = textwrap.dedent(
f"""
你是 CodeSentinel 的规范审查模块。请仅依据给定规则与代码片段判断合规性。
必须输出严格 JSON(不要 Markdown),字段如下:
{{
"compliant": true/false,
"confidence": 0到1的小数,
"severity": "low|medium|high",
"reasoning": "简短中文理由",
"citations": [{{"path":"...", "line": 1, "snippet":"..."}}]
}}
输入数据(JSON):{json.dumps(payload, ensure_ascii=False)}
"""
).strip()
try:
out = self._runnable.invoke(prompt)
text = out if isinstance(out, str) else str(out)
data = json.loads(text)
except Exception as exc: # noqa: BLE001 - 教学示例聚合异常
return RuleVerdict(
rule_id=rule.rule_id,
status="error",
severity="medium",
channel="llm",
message=f"LLM 评估失败: {exc}",
evidence={},
)
compliant = bool(data.get("compliant"))
status = "passed" if compliant else "violated"
return RuleVerdict(
rule_id=rule.rule_id,
status=status,
severity=str(data.get("severity", "medium")),
channel="llm",
message=str(data.get("reasoning", "")),
evidence={"confidence": data.get("confidence", 0.0), "citations": data.get("citations", [])},
)
def build_fake_llm_evaluator() -> LLMRuleEvaluator:
from langchain_core.language_models.fake import FakeListChatModel
fixed = json.dumps(
{
"compliant": True,
"confidence": 0.86,
"severity": "low",
"reasoning": "(Fake 模型)未观察到明显违背规则之处。",
"citations": [],
},
ensure_ascii=False,
)
llm = FakeListChatModel(responses=[fixed])
return LLMRuleEvaluator(runnable=llm)
# -----------------------------
# Orchestrator:混合编排
# -----------------------------
class ComplianceOrchestrator:
def __init__(
self,
parser: AgentsmdParser,
engine: RuleEngine,
llm_eval: Optional[LLMRuleEvaluator],
) -> None:
self._parser = parser
self._engine = engine
self._llm_eval = llm_eval
def evaluate_agents_md(self, agents_md: str, files: Sequence[FileSnapshot]) -> ComplianceReport:
rules = self._parser.parse_agents_md(agents_md)
verdicts: List[RuleVerdict] = []
for rule in rules:
if rule.kind in (RuleKind.IMPORT_BAN, RuleKind.REGEX):
verdicts.append(self._engine.evaluate(rule, files))
continue
if rule.kind == RuleKind.LLM_SUBJECTIVE:
if self._llm_eval is None:
verdicts.append(
RuleVerdict(
rule_id=rule.rule_id,
status="skipped",
severity="info",
channel="llm",
message="未配置 LLM 评估器",
evidence={},
)
)
else:
verdicts.append(self._llm_eval.evaluate(rule, files))
continue
verdicts.append(self._engine.evaluate(rule, files))
return ComplianceReport(items=verdicts)
def demo() -> None:
agents_md = textwrap.dedent(
"""
## 架构约束
- 禁止从 `requests` import(示例:强制走内部 http client)
- 遵循整洁架构:领域层不得依赖基础设施细节
## 代码风格
- 代码中不得出现 debug 打印:regex:print\\(
"""
).strip()
bad_py = textwrap.dedent(
"""
import requests
def fetch():
print("debug")
return requests.get("https://example.com")
"""
).strip()
files = [FileSnapshot(path="app/service.py", content=bad_py)]
orch = ComplianceOrchestrator(
parser=AgentsmdParser(),
engine=RuleEngine(),
llm_eval=build_fake_llm_evaluator(),
)
report = orch.evaluate_agents_md(agents_md, files)
for v in report.items:
print(v)
if __name__ == "__main__":
demo()
运行结果预期:import requests 与 print( 会触发确定性违规;整洁架构 条目走 LLM(Fake 固定返回 compliant=true 用于演示链路)。
生产环境实战:落地时你会遇到的 6 个坑
- 相对导入与包路径:AST 看到
from ..x import y时,没有完整包上下文会误判或无法解析。生产应传入sys.path根、包名映射,或先做 importlib 解析。 - 大文件与上下文窗口:不要把整个仓库塞进 LLM。按「变更 diff + 相关模块拓扑」裁剪上下文;对 LLM 规则执行「文件门控」(只送可能相关的路径)。
- 规则漂移:
AGENTS.md更新后,旧 PR 用哪版规则?要有ruleset_version(下一讲展开)。 - LLM 输出不可信:对
citations做存在性校验;对line做边界检查;失败降级为error而非静默当 pass。 - 性能:AST 扫描可缓存 per-file hash;确定性规则并行执行;LLM 批处理要注意速率限制与费用。
- 合规与隐私:代码片段可能含密钥。送 LLM 前做 secret scan 与脱敏。
生产扩展清单:把演示代码抬到可上线水准
观测性:为每条规则记录耗时、token 用量、模型版本、prompt hash(不要记录明文代码)。缓存:对 (ruleset_version, file_hash, rule_id) 做确定性结果缓存;LLM 结果缓存要慎重,避免把旧结论套到新代码上。权限:解析与执行应在受控 worker 中运行,避免任意仓库拉取导致 SSRF 或泄露。多语言:若仓库含 TypeScript、Go,AST 通道需要插件化,不要把 Python AST 硬编码成唯一真理来源。
与评审流程结合的落地姿势(建议)
在 PR 打开时,CodeSentinel 拉取「该 PR 基线提交对应的规范版本」与「HEAD 代码快照」,先跑确定性规则,生成可快速修复的 finding;再对变更文件集合跑 LLM 规则,生成需要架构判断的 finding。对「仅影响文档」的 PR,可以跳过 LLM 以节省成本;对「改动触及 public API」的 PR,可以提高 LLM 规则权重并强制人工复核。这样你得到的不只是一个工具,而是一套随风险动态调节的治理策略。
失败模式案例(便于你在团队内对齐预期)
案例 A:规则写得太抽象。例如「代码要保持高质量」。解析器只能把它丢给 LLM,结果波动大。治理办法是把抽象目标拆解为可检查清单,并把可判定部分下沉到确定性规则。案例 B:规则互相冲突。例如一条要求「快速交付」,另一条要求「所有函数必须写满注释」。CodeSentinel 应在规范仓库层做冲突评审,而不是让模型在 PR 里随机仲裁。案例 C:误报淹没真问题。确定性规则过宽会导致大量 medium finding,研发开始无视平台。应对办法是引入豁免机制、路径级策略、以及「同类问题聚合」。
本讲小结(Mermaid Mindmap)
mindmap
root((第20讲\n规范解析引擎))
矛盾
Markdown 友好
机器需可判定
解析
分节
列表条目化
kind 分类
确定性
Regex
AST import
LLM
结构化 JSON
证据引用
失败降级
编排
先确定性
后主观
可审计输出
思考题
- 如果团队坚持「规则全部自然语言书写」,你如何设计一层 DSL 或注释标签 来降低解析歧义,同时不牺牲人类可读性?
- 当确定性检查与 LLM 结论冲突时,CodeSentinel 应如何在 UI 上呈现优先级与复核工作流?
- 你会把哪些「架构类」规则从 LLM 迁出,改为依赖图/模块边界工具(例如 import-linter)?
深度延展:把规范解析引擎做成平台能力(写给要落架构的读者)
如果你把 CodeSentinel 只当作一个给拉取请求打标签的附属工具,它的价值会很快触顶。更可持续的定位是:规范解析与执行引擎成为研发平台的共享内核,让规范以同一套数据结构进入集成开发环境、持续集成、代码搜索、以及人工智能助手的工具调用。要做到这一点,你需要提前设计四类边界,否则半年后一定会遇到「改一处崩三处」的维护灾难。
第一类边界是输入边界。AGENTS.md 不是唯一输入。未来还会出现团队策略文件、来自合规系统的条款映射、以及按业务线划分的规则包。解析层最好抽象为策略文档接口:包含来源地址、版本号、原始文本、格式类型。AgentsmdParser 只是其中一个适配器。这样你不会被 Markdown 绑架,也不会在第二种格式出现时重写编排器。
第二类边界是执行边界。确定性执行器不应写死在单一类里,而应注册为检查器插件:名称、支持的规则类型、评估函数。例如 Python 导入检查器、正则行扫描器、以及外部语义grep执行器。编排器只做调度与超时控制。新增工具链时,团队不需要触碰核心解析逻辑,降低回归风险。
第三类边界是证据边界。无论是抽象语法树命中还是大语言模型引用,最终都要落到统一的证据结构:路径、行号范围、片段哈希、工具名称。这会让你的合规报告在六个月后可复现:你至少能回答这条发现项是哪个工具、依据哪段文本。对语言模型还要额外保存模型标识、温度参数、提示词版本,否则审计人员会质疑结论不可追溯。
第四类边界是产品边界。研发体验上,最忌讳同一问题被三个系统用三种口径报三遍。CodeSentinel 应该把规范执行结果归一到发现项流,和人工评审、静态分析、安全扫描共用严重级别与去重策略。去重可以按规则编号加路径加行号分桶,或按语义向量近似聚类,这部分可以在后续智能化章节继续展开。
再谈一个常见决策:要不要让大语言模型自动改写代码来修复违规?在规范治理场景,自动改写风险并不低:它可能修掉导入违规却引入语义变化。更稳妥的产品路径是:确定性违规提供一键修复模板,可审计、可回滚;语言模型提供建议补丁但必须走额外确认与测试门禁。CodeSentinel 作为治理平台,应优先保证可解释、可回滚、可追责,而不是追求炫技式自动提交。
最后给出一套从零到一的落地节奏,便于你在团队内推进。第一周只上导入黑名单与少量正则,并把报告接到合并请求评论。第二周引入规则集版本与规范变更评审流程。第三周引入主观规则,但默认只产出低级别建议。第四周根据误报率调参,并把高频语言模型规则逐步沉淀为确定性规则。这个节奏的核心思想是先建立信任,再扩大权力。规范引擎一旦失去研发信任,就会被旁路成仅供参考,平台化目标也就失败了。
你还可以把「规则解析引擎」与「代码变更影响分析」联动:当改动触及公开接口、持久化模型、鉴权边界时,自动提高某些规则的严重级别,并要求额外审批人。这样规范不再是静态文本,而是随风险动态变化的治理策略集合。与此同时,要为平台管理员提供规则模拟器:上传一段代码快照与一份规则草案,先在沙箱里跑一遍,统计误报率与覆盖率,再决定是否合并到主规则集。没有模拟器,规范团队就会被线上投诉驱动,陷入无休止的参数拉扯。
为了让研发同学真正愿意使用 CodeSentinel,还需要把规则失败翻译成可执行的下一步:链接到示例修复、链接到内部文档、链接到模板代码。规范引擎如果只输出冷冰冰的不通过,人们会把它当成噪音;如果输出带有路径指引与可验证的复现步骤,它就会变成日常工程习惯的一部分。最终你会发现,解析与执行只是手段,把组织经验沉淀成可传播、可演进、可度量的规则资产,才是 CodeSentinel 在架构治理里的长期价值。
下一讲预告
第 21 讲将转向规范的 Git 化演进:把 AGENTS.md 当作代码一样版本管理,建立 PR 评审、变更日志、合规度量与多仓分发流水线,让 CodeSentinel 不仅能「执行规则」,还能跟踪「规则本身如何变」。
附录:Orchestrator 决策表(Mermaid)
flowchart TD
Q1{"RuleKind?"}
Q1 -->|IMPORT_BAN / REGEX| E["RuleEngine.evaluate"]
Q1 -->|LLM_SUBJECTIVE| L{"LLM 已配置?"}
L -->|是| M["LLMRuleEvaluator.evaluate"]
L -->|否| S["skipped"]
Q1 -->|UNKNOWN| E
E --> V["写入 ComplianceReport"]
M --> V
S --> V