Skip to content

Latest commit

 

History

History
193 lines (131 loc) · 25.3 KB

File metadata and controls

193 lines (131 loc) · 25.3 KB

RegPilot 架构总览

本文档是导航层,不是某一份具体技术文档。目标是让第一次接触本仓库的人,在阅读各份技术文档之前,先建立起"系统是什么、三层图结构如何咬合、State 怎么流动、遇到具体问题该去哪份文档"的整体认知。字段级、函数级细节一律不在此展开,均在对应技术文档或 SCHEMA_REFERENCE.md 中权威定义。


一、系统是什么

RegPilot 是一个面向跨境电商卖家的出海合规情报 Agent:给定「目标市场(可一个或多个)+ 产品类别 + 可选的产品参数/销售上下文」,系统自动完成——

  • 把用户的自由文本追问澄清为标准化的市场/品类/产品属性;
  • 对每个目标市场并行规划应检索的法规清单、执行多轨检索;
  • 判定每条检索结果的身份归属与可信度,对过期或存疑条目做二次补全;
  • 对已确认适用的法规进一步深挖测试项/认证流程/预估周期与费用;
  • 若涉及多个市场,跨市场对比结果、给出结构性冲突提示与优先级排序;

最终输出一份或多份结构化合规情报报告——报告中明确标注哪些条目可直接采信、哪些需要人工核实、哪些是系统兜底占位。

当前版本聚焦单一品类(便携式充电宝)、覆盖 EU / US / UK / JP / KR / AU / SA·UAE / IN / TH / BR 共10个市场,架构上刻意保持对品类/市场扩展开放(属性驱动过滤机制专为此设计,见第五节)。


二、工作流拓扑:三层图结构

系统由三个独立编译的 LangGraph 组成,自底向上分别解决三个不同层面的问题:输入怎么澄清清楚parse_graph)→ 单个市场怎么跑完整条合规检索链路main_graph)→ 多个市场怎么并行调度、结果怎么汇总对比orchestrator_graph)。三者边界清晰、职责不重叠,main_graph 不知道自己是被单市场直连调用还是被编排层的某个分支调用,orchestrator_graph 也不关心 main_graph 内部七个节点具体做了什么。

flowchart TD
    subgraph parse_graph["parse_graph(独立子图,main.py::resolve_user_input() 驱动)"]
        PI[parse_input<br/>统一解析:市场列表/品类/产品属性/<br/>销售上下文/未映射内容,含幻觉防护]
        CMC[clarify_market_category<br/>市场或品类未识别时的追问中断]
        AG[attribute_gateway<br/>disputed_threshold法规的<br/>数值阈值结构化澄清]
        PI -->|市场/品类未识别且未超限| CMC
        PI -->|解析成功| AG
        CMC -->|resume后回到| PI
        AG -->|本轮仍有待澄清属性,resume后回到| PI
    end

    subgraph orchestrator["orchestrator_graph(顶层编排,main_orchestrator()调用)"]
        FANOUT[fan_out<br/>为每个目标市场生成一个Send任务]
        RM[run_market<br/>子进程内完整跑一次main_graph]
        JOIN[join_markets<br/>barrier汇合节点]
        COMPARE[compare<br/>跨市场统计对比+优先级排序+冲突探测]
        FINALIZE[finalize<br/>渲染最终对比报告]
        FANOUT -.Send.-> RM
        RM --> JOIN --> COMPARE --> FINALIZE
    end

    subgraph main_graph["main_graph(单市场主流程,在独立OS进程内运行)"]
        A[input] --> B[plan] --> C[retrieve] --> D[identify] --> E[validate] --> F[reflect] --> G[deep_dive] --> H[report]
    end

    parse_graph -->|解析完成后main.py构建run_id并调用| orchestrator
    RM -.进程内完整调用.-> main_graph
Loading

三层图为什么不能合并:

  • parse_graph 含多处 interrupt(),必须携带 checkpointer 才能编译;main_graphorchestrator_graph 目前均不含任何 interrupt() 调用(2026-08 起 deep_dive 阶段的 HITL 真中断已删除,见第五节),但仍保留 checkpointer 支持崩溃恢复,三者对"是否需要中断能力"的诉求本就不同,不适合共用一张图。
  • main.py 需要先拿到 parse_graph 解析出的标准化 target_markets/product_category,才能构建 run_id(单市场格式 <market_slug>_<category_slug>_<时间戳>,编排层格式 multi_<category_slug>_<时间戳>)。如果三层揉进一张图,run_id 无法在流程一开始就确定。
  • orchestrator_graph 的并行分支(run_market)通过 ProcessPoolExecutor 在独立操作系统进程中执行完整的 main_graph,理由见下方"进程级隔离"一节——这决定了 main_graph 必须是一个可以被"整体丢进子进程跑一遍"的独立单元,不能和编排层共享同一进程内的图实例。

2.1 parse_graph:统一的输入解析与澄清

parse_graph 只有一个真正做事的节点 node_parse_input,另外两个节点(clarify_market_category / attribute_gateway)只负责"暂停、等答案、把答案拼回 raw_user_input、路由回 parse_input",本身不调用任何 LLM。这是刻意的架构选择:

  • LangGraph 的 interrupt() 恢复语义是"整个节点函数从头重新执行"。如果 interrupt() 调用点直接放在 node_parse_input 内部,resume 后会把该节点已经做过的 LLM 抽取调用重新触发一次(这次调用不会被自动缓存重放),既浪费成本又可能产出与第一次不同的结果。因此含 interrupt() 的节点被拆成两个轻量壳节点,各自 resume 时重新执行的只是"拼接字符串 + 自增计数器"这几行代码,代价可忽略。
  • node_parse_input 本身不含 interrupt(),可以安全地在每次 resume 后重新执行——它每次都是对累积到当前为止的全部用户输入文本做一次全新的、完整的重新解析(市场列表、品类、产品属性、销售上下文、未映射内容一次性抽全,同一份 prompt 同时覆盖首次输入和澄清答案两种场景)。这正是"自我纠错能力"的来源:用户前面说错/说漏的内容可以在后续轮次里被自然纠正。

两类追问循环共享同一个"回答→拼回原文→整体重新解析"的模式,但触发条件完全不同:

追问类型 触发条件 驱动节点 上限
市场/品类未识别 LLM 抽取后 target_markets 为空或 product_category 为空 clarify_market_category MAX_CLARIFICATION_ROUNDS(3轮)
disputed_threshold 数值阈值待确认 某条法规的 disputed_threshold 依赖的产品属性尚未知晓(evaluate_disputed_threshold() 返回 unconfirmed attribute_gateway MAX_ATTRIBUTE_CLARIFICATION_ROUNDS(5轮)

attribute_gateway 的终止条件对称设计:本轮没有真正需要问的属性(全部 applies/not_applies/用户已明确拒绝)→ 结束;达到轮数上限 → 剩余属性强制转入 unconfirmed(等价于该机制不存在时的原有行为,不会让流程卡死)。用户在追问过程中若明确表示"不知道/跳过",该属性名进入 opted_out_attributes,本轮及后续轮次都不再追问,但如果用户后续又主动提供了这个值,仍然以实际值为准——"要不要问"和"有值时怎么判"是两件独立的事。

2.2 main_graph:单市场主流程

main_graph 是一条固定拓扑的线性链:input → plan → retrieve → identify → validate → reflect → deep_dive → report,无分支、无并行、无运行时才决定的路径选择。节点间仅通过 RegPilotState 传递数据,禁止直接调用;identify 判定的 regulation_id 一经写入,下游节点全程只读。

input 节点的职责很薄——只做市场名/品类名的字符串归一化(欧盟成员国映射、英文代码识别、别名解析),真正的"从用户自由文本理解意图"这件事已经由上游 parse_graph 完成,main_graph 拿到的输入始终是标准化过的。

2.3 orchestrator_graph:多市场编排层

单市场与多市场统一走同一条 Send fan-out 路径——哪怕只有1个市场,也是 fan-out 长度为1的特例,不为"单市场"单独维护一条捷径逻辑。这是"单一权威实现"原则在编排层的落地:只维护一套 run_market 节点逻辑,不允许存在第二套"单市场专用"的调用路径与之并行漂移。

两层并行,两个不同的机制在起作用:

  1. 图结构层面:LangGraph 的 Send API 负责"调度哪些市场分支、结果如何合并回 OrchestratorState"。同一 superstep 内的多个 Send 目标是并行调度的(不同线程分别调用)。
  2. 进程级隔离:每个 run_market 节点内部用 ProcessPoolExecutor(max_workers=1) 把真正的工作(跑一次完整 main_graph)提交到一个独立操作系统进程执行。这不是为了榨取额外的并行度,而是隔离性要求——现有实现里 sys.stdout/sys.stderr 的全局替换(_Tee)、llm_audit.py 的审计采样计数器、llm_client.py 的模块级调用日志,都是进程级全局可变状态,靠 REGPILOT_RUN_ID/REGPILOT_RUN_DIR 环境变量 + 每次运行开始时 reset_*() 归零。这套设计对"一个进程只跑一个市场"成立;若在同一进程内用线程并发跑多个市场,这些全局状态会互相踩踏(A市场日志混进B市场 run.log,审计采样计数器被跨市场共享)。选择进程级隔离而非重构这些全局状态为显式传参,是刻意决定——不牵连现有审计/指标体系的既有实现,代价是每个市场分支多一次进程启动开销,在当前 10 个市场量级下可接受。

_fan_out_to_markets() 必须显式透传的字段(易漏字段,已有过真实 bug 记录):product_attributes / attribute_source / disputed_threshold_judgments / unmapped_user_context 都需要随 Send payload 一起传给每个市场分支,否则该市场分支内的 node_plan() 会读到空字典,disputed_threshold 的排除/确认结果全部静默丢失、报告里的"用户提及但未参与判断"展示区恒为空——这类字段不参与图结构本身的路由决策,容易在新增字段时被遗漏透传,新增编排层字段时需要同时检查 _fan_out_to_markets()run_single_market() 的参数列表两处。

容错边界:某个市场分支执行失败(main_graph.invoke() 抛出异常,或子进程在进入该逻辑之前就崩溃),run_market 节点统一兜底为 {"status": "error", "error": ..., "stage": ...},不会让单个市场的故障演变成整个编排层的崩溃——node_compare/node_finalize 按"显示但标注失败"的原则渲染:失败市场的统计列不展示任何数字,报告顶部显式列出失败原因,其余市场正常展示。

compare 节点承担三件事,且三者在实现方式上遵循同一条纪律——排序/探测的数字或布尔结论必须是规则计算,LLM 只负责把已经算好的结构化数据组织成人类可读的文案

  • 统计对比:复用 build_report_summary_stats()(与单市场报告完全同一份口径,不重新定义"什么算 normal/什么算 forced_retain");
  • 跨市场优先级排序compute_priority_ranking()):确定性加权公式(法规强制性 × 生效紧迫度 × 市场层级 × 销售上下文四维),与单市场报告里 node_plan.py 展示的优先级参考共用同一套 priority_scoring.py 实现,LLM 不参与排序数字本身的产生;
  • 结构性冲突探测detect_structural_conflicts()):只识别能直接从已有字段读出的事实性冲突(同一 regulation_id 在不同市场分支的最终 status 不一致),是规则判断,不是"多个都说得通、依赖权衡"的多解判断,因此不用 LLM。探测到冲突后才调用一次 LLM(generate_conflict_warnings())把冲突事实、排序前列条目、deep_dive 的周期/费用预估组织成警示文案,未检测到冲突时不触发这次调用。

三、State 如何流转

系统有两套独立的 State 定义,不共享字段结构,编排层不裁剪、不重新解释 main_graph 内部字段:

  • RegPilotStateparse_graphmain_graph 共用同一个 State 类型(parse_graph 只写其中"第0/1层"字段)。分层组织:第0层输入解析结果、第1层用户输入(市场/品类/产品属性/销售上下文)、第2层规划结果、第3层检索结果、第3.5层身份解析结果、第4层验证结果、第5层补全结果、第5.5层深挖结果、第6层最终报告。每层对应哪个节点写入,字段全集见 state.py 源码注释与 SCHEMA_REFERENCE.md
  • OrchestratorState:编排层专用,独立于 RegPilotState。每个市场分支产出的完整 RegPilotState 作为不透明字典存入 market_results[market_code]node_compare 直接消费其内部字段(如 supplemented_regulations/deep_dive_results),不做二次转换。market_results/partial_failures 两个字段通过 Annotated[..., operator.or_] / Annotated[..., operator.add] 声明 reducer,满足 LangGraph 对"多个并行分支写同一 channel 必须提供合并函数"的要求。

disputed_threshold 判断结果的完整生命周期(跨越三层图,是本轮改造里流转路径最长的一个字段族,值得单独说明):

node_attribute_gateway(parse_graph内部,可能触发interrupt多轮)
  → disputed_threshold_judgments 写入 RegPilotState
  → main.py 读出后放入 OrchestratorState 初始状态
  → orchestrator_graph._fan_out_to_markets() 透传进每个市场分支的 Send payload
  → market_branch_worker.run_single_market() 透传进该市场分支的 main_graph 初始State
  → node_plan():not_applies的法规从applicable_regs剔除,applies的法规记入
    disputed_threshold_confirmed_regulations(写入plan_metadata,不影响其"适用"身份)
  → node_validate() / node_reflect():读plan_metadata.disputed_threshold_confirmed_regulations,
    命中的法规在relevance打分时不再走disputed展示模板(IDENTITY_CONTEXT_DISPUTED_TEMPLATE)
  → node_deep_dive():命中的法规不再抑制结构化深挖结论(disputed_suppressed分支不触发)
  → node_report():plan_metadata里的excluded/confirmed两个列表在报告概览表里透明展示

这条链路里,只有 node_attribute_gateway 产生判断,下游全部节点只读——与 regulation_id 一旦由 node_identify 写入即全程只读是同一条架构纪律的延伸。


四、技术文档地图

以下是当前架构下应当存在的技术文档全集,按"关注点/审计线"划分,而非按代码文件划分——每份文档贯穿多个节点,回答一个独立的问题。相比更早期的版本,本轮拓扑重构后新增了两份独立文档(输入解析与澄清、编排层),并将原"判分审计"更名扩容为覆盖全部四条 LLM 决策审计线。

文档 回答的问题 主要贯穿节点/文件
输入解析与澄清架构 用户的自由文本如何变成标准化的市场/品类/产品属性;市场品类追问与属性数值追问两类多轮循环如何在同一套"回灌重解析"机制下统一驱动;幻觉防护如何实现 node_parse_inputnode_clarify_market_category / node_attribute_gateway(parse_graph)
检索与规划架构 系统如何决定搜什么、从哪搜;YAML配置如何驱动检索策略;产品属性如何过滤法规清单(applies_when);确定性优先级打分如何计算 planretrieve
过滤架构 一条法规从进入系统到出现在报告里,经历了哪几层过滤(域名准入、双轨检索、身份归属、标题粗筛路由、相关性判定、状态判定、二次补全、报告展示) retrieveidentifyvalidatereflectreport
Deep Dive与决策支持架构 系统如何判断"值不值得深挖";LLM如何自主判断信息是否足够、该续搜什么;disputed法规的结论抑制逻辑;结果如何嵌入单市场报告与跨市场警示文案 deep_dive(含 node_report/node_compare 对其产出的消费方式)
编排层架构 单市场与多市场如何统一走同一条 Send fan-out 路径;为什么用进程级隔离而非线程/协程;跨市场统计对比/优先级排序/结构性冲突探测各自的确定性边界在哪;某市场分支失败时报告如何降级展示 orchestrator_graph / market_branch_worker / node_compare
LLM决策审计(轨道C) 四条独立的LLM决策记录现场(相关性判分、二次搜索gap诊断、deep_dive充分性判断、输入解析抽取)各自如何采样、记录、人工标注、回归验证准确率 validate/reflect(判分+gap审计埋点)、deep_dive(充分性审计埋点)、parse_input(抽取审计埋点),均独立于主图运行
测试与指标体系 如何验证系统正确性(回归测试轨道,含 plan/retrieve 专属测试与 config 完整性校验);如何度量真实运行表现(指标采集轨道,含 parse 阶段成功率) 全节点覆盖,独立于主图运行
Reporting统计体系 处理结果如何被计数、分类、汇总进单市场报告展示层;两个统计口径如何保证一致;deep_dive/disputed_threshold相关的展示行如何接入概览表 reflectreport

配置字段/数据结构的权威定义速查见 SCHEMA_REFERENCE.md(非"关注点"文档,纯索引性质,不复制字段细节)。

与更早期版本的结构差异说明(帮助读者理解为什么文档集合变了,不是变更日志,只陈述当前事实):此前"检索与规划架构"文档内部包含过一节关于 node_parse_input 的说明,现已完整拆出独立成篇——输入解析这一层现在承担的职责(统一单/多市场解析、两类追问循环、attribute_clarification_status 判断用户是否拒答)已经远超"顺带一提"的体量,值得独立审计线。原"判分审计"文档现更名为"LLM决策审计",因为 llm_audit.py 现在承载的记录现场已从单一的"relevance打分"扩展到四条独立线,继续用旧名字会让读者误以为其余三条审计现场不存在正式文档覆盖。


五、核心设计原则速览

以下每条从对应技术文档中提炼,完整论证过程见各文档正文:

  • YAML-first:法规元数据(分类、搜索词、状态、applies_whendisputed_threshold)唯一权威来源是 markets.yaml,代码只读不重复维护逻辑。新增市场/法规/产品属性只改配置,不改代码。
  • 单一权威来源:任何需要计算的结果(regulation_id 判定、报告统计口径、优先级分数、disputed_threshold 判断)只有一处产生,其余消费方引用而非各自重算。
  • 身份一旦确立,全程只读regulation_id 只由 node_identify 写入一次;disputed_threshold_judgments 只由 node_attribute_gateway 写入一次;下游节点无论跨了多少层 State 传递,都只读不重新推断。
  • 有界自主性优于自由决策:本项目里真正引入 LLM 自主判断的几个点——node_reflect 的 gap-driven 单步重试(三选一预定义动作空间 + 硬性重试上限)、node_deep_dive 的充分性判断循环(开放式 next_query 生成,但同样受硬上限约束)、node_compare 的警示文案生成(LLM 只组织已算好的结构化事实,不产生判断本身)——无一例外都是"在明确定义的决策点做受限选择",而不是完全自由发挥的 ReAct 式循环。这不是能力不足的妥协,而是刻意的架构判断:node_plan/node_reflect 的核心检索逻辑早期曾尝试过完全 LLM 驱动的策略生成,验证后发现在 YAML-first + 强身份约束下,决策空间过窄导致 LLM 输出收敛到与规则等价的结果,遂将真正需要"存在多个合理分支、依赖上下文权衡"这一前提成立的场景(多市场优先级排序、结构性冲突处理、单条法规的信息充分性判断)识别出来,把自主性预算集中投入在这些地方。
  • 评估必须真实,成本优化只能作用于评估方式node_validate 的 fast/full 双档路由省的是模型档位和 prompt 长度,不是砍掉评估动作本身;node_deep_dive 的结构化抽取阶段禁止编造具体数字,抽不到就用统一占位文案,不允许"看起来像是查到了"的伪信息进入报告。
  • 容错优先于严格,但仅限旁路组件:审计埋点(四条审计线)、指标采集(metrics_collector.py)失败不能拖累主流程;但 parse_graph 的幻觉防护、node_validate 的硬规则否决点不适用此原则——业务判断本身该严格的地方不能因为"想更容错"而放松。
  • 进程边界服务于隔离,不服务于计算并行:编排层的多进程设计是为了隔离进程级全局可变状态,代价(每市场一次进程启动开销)在当前市场量级下被认为是合理的架构取舍,不是尚未优化的性能瓶颈。

六、架构边界与当前局限

已经不再成立的局限(早期版本曾明确列出,现已被本轮重构解决,收录于此是为了避免读者被历史文档误导):无法处理"同一SKU卖多个市场,对比认证差异并给出优先级"——orchestrator_graph + node_compare 已完整实现这一能力(并行检索、跨市场统计对比、结构性冲突探测、确定性优先级排序)。无法感知产品参数——product_attributes + applies_when + disputed_threshold 三件套已经支持"电池容量分级""是否含无线充电"等属性驱动的法规适用性判断,且能对连续数值型的争议阈值(如电压)做结构化多轮追问。停留在信息汇总、未触达决策——node_deep_dive 已经能产出具体测试项、认证流程、预估周期与费用区间。

仍然成立的局限

  • node_plan 的检索关键词生成本身仍是确定性的markets.yaml 权威给定法规清单,LLM 不参与清单的生成或增删,只做已确定清单之上的确定性排序打分(不再有 LLM 排序调用,已彻底移除,详见"检索与规划架构"文档的移除说明)。这是刻意保留的边界,不是待办——防幻觉的核心机制正是"法规清单永远来自配置,不来自模型记忆"。
  • node_reflect/node_validate 的核心过滤逻辑仍是规则引擎:身份归属(source_tag直通/正则匹配)、状态判定(determine_status() 硬规则集)都不是"模型在多个可选动作间做判断",这部分被验证过决策空间过窄、不适合 LLM 化(见第五节)。
  • 产品属性目前只有充电宝一个品类接入真实法规数据CATEGORY_DEFAULT_ATTRIBUTES 里其余品类(耳机/门锁/手表/路由器)已预留但注释掉,扩展到这些品类前需要先补齐对应的 regulations[]/applies_when 配置,属性驱动过滤机制本身已就绪,不需要改动任何节点代码。
  • 认证服务/费用参考数据尚无独立护栏层node_deep_dive 目前直接从检索内容中抽取周期/费用信息,抽不到就诚实占位,但还没有一张独立维护的"合规服务参考数据层"(认证机构名录、费用区间参考表)来进一步降低幻觉风险,这类数据层如果未来接入,需要与现有"regulation_id 不可由LLM生成"同等严格的护栏设计。

七、架构演进:从固定规则到有界自主

这一节回答"这个系统现在处于 workflow 和 agent 光谱的哪个位置",用的是行为事实而不是宣称。

判断标准:执行路径是否在运行时才被决定,以及决定路径的判断是否依赖无法预先枚举的上下文。main_graph 内部七个节点的执行顺序在编译期已完全确定,这部分是纯 workflow,不需要也不应该伪装成别的东西。真正体现"运行时判断"的地方,集中在三处,且全部满足"存在多个合理分支、分支选择依赖具体上下文"这个前提:

  1. orchestrator_graph 的市场级并行调度:要跑几个市场分支、某个市场失败了其余是否继续、跨市场结果冲突时怎么呈现——这些不是预先写死的固定流程,Send fan-out 的目标数量由当次用户输入决定,node_compare 的冲突探测结果因运行而异。
  2. node_deep_dive 的信息充分性判断循环:给定一条已确认适用的法规,LLM 判断"当前搜集到的信息是否足以回答测试项/流程/费用",不足则自主生成下一步查询词、判断信息缺口类型(gap_type 三选一),这个判断依赖当次检索到的具体内容,不是预设模板问题列表能覆盖的。
  3. 跨市场优先级排序与结构性冲突处理:排序依据的四个维度权重是确定性公式,但"哪些法规进入排序表、冲突警示该强调哪几点"依赖当次多市场运行的实际结果组合,运行前无法预知。

刻意不做的事:让 node_plan 的检索策略生成、node_validate/node_reflect 的核心过滤规则变成 LLM 自由决策。这两处的决策空间在"法规清单必须100%来自YAML、不允许LLM编造"这条护栏下天然很窄,任何合理输入都会收敛到与确定性规则等价的结果,引入 LLM 只会增加不可预测性和成本,不会带来真正的判断力提升。有界自主性的预算被有意识地集中在"决策空间真实存在分支"的三个点上,而不是均匀撒在每一个节点里。