语义一致性仲裁系统:ADK契约引擎+Agent SDK协同验证
1. 项目概述这不是一个“模型评测工具”而是一套可落地的语义一致性仲裁系统“Building a Semantic Model Referee With Google ADK and the OpenAI Agent SDK”——这个标题里藏着三个被日常讨论严重稀释的关键词“Semantic Model”语义模型、“Referee”裁判/仲裁者、“ADK”Application Development Kit。很多人第一反应是“哦又一个用大模型测大模型的玩具项目”但实操过就知道它解决的是当前AI工程化中最棘手、最沉默的痛点当多个模型对同一输入给出逻辑自洽却彼此冲突的输出时你信谁比如客服系统里RAG模块返回了合同条款原文微调后的领域模型却给出了与之矛盾的解释而规则引擎又判定该场景必须走人工复核——此时没有“错误答案”只有“不一致答案”。这个项目要做的不是挑出错的那个而是构建一个能理解“为什么一致”、判断“在什么维度上该一致”的第三方仲裁层。它不替代任何模型而是给整个AI流水线装上一套语义级的质量门禁。核心关键词——语义一致性、模型仲裁、Google ADK、OpenAI Agent SDK、多模型协同验证——全部指向一个目标让AI系统的输出从“看起来合理”升级为“经得起逻辑推敲”。适合三类人深度参考正在设计企业级AI工作流的架构师你需要知道如何插入仲裁点、做模型融合与路由的算法工程师你需要理解一致性校验的粒度与代价、以及负责AI交付验收的产品与QA负责人你需要一套可解释、可审计的评估依据。它不是教你怎么调参而是教你怎么建立信任。2. 整体设计思路为什么必须用ADKAgent SDK双框架而不是单一大模型2.1 核心矛盾语义仲裁的本质是“跨模态推理”不是“单点打分”我最初也试过纯用OpenAI Agent SDK写一个“一致性检查Agent”喂它两个模型的输出让它判断是否一致。结果很挫败——它要么泛泛而谈“两者都提到了价格但细节不同”要么直接武断下结论“模型A更准确”。问题出在底层Agent SDK本质是LLM的流程编排器它擅长调度、记忆、工具调用但不擅长定义“语义一致性”的计算边界。它无法告诉你“价格”这个词在金融合同里必须精确到小数点后两位在电商评论里允许模糊表述为“不贵”这种领域敏感的语义锚点需要结构化定义。而Google ADKApplication Development Kit的核心价值恰恰在于它提供了一套可编程的语义契约Semantic Contract建模能力。ADK不是另一个大模型而是一个轻量级的、基于Schema的语义中间件。它让你用YAML或JSON Schema明确定义“对于‘付款期限’这个字段合法值必须是ISO 8601日期格式且不能早于合同签署日”“对于‘违约责任’段落必须包含‘赔偿’、‘解除’、‘继续履行’三个关键词中的至少两个”。这些不是提示词而是可执行、可验证、可版本化的契约。所以整体架构不是“ADK or Agent SDK”而是“ADK as the semantic rule engine, Agent SDK as the orchestration brain”。2.2 架构分层解析四层解耦每层解决一个具体问题整个Referee系统被严格划分为四个物理隔离、职责清晰的层这是保证其可维护性和可审计性的关键Input Normalization Layer输入归一化层所有上游模型无论来自Llama、Claude还是本地微调模型的原始输出首先进入此层。它不做语义判断只做三件事a) 统一文本编码UTF-8 BOM清理b) 剥离非内容标记如Markdown渲染符号、XML标签c) 对长文本按语义单元切分例如以句号、分号、换行符为界但跳过引号内的标点。这一步看似简单但实测发现超过65%的“假不一致”源于模型输出格式差异——一个返回{price: ¥100}另一个返回价格100元归一化后都变成price: 100才能进入下一步比对。Semantic Contract Engine语义契约引擎这是ADK的核心战场。我们为每个业务场景如“贷款审批”、“保险理赔”定义独立的.adk.yaml契约文件。以“贷款年利率”为例契约包含field: annual_interest_rate type: number constraints: min: 0.0 max: 36.0 format: percentage_2dp # 要求保留两位小数 context_dependency: # 上下文依赖规则 - field: loan_term_months condition: gt(12) requirement: lt(24.0) # 期限12个月利率必须24%ADK会将归一化后的文本通过其内置的轻量级NLU解析器基于spaCy定制提取实体、数值、关系并与契约逐条比对。它不生成新文本只输出结构化验证报告{field: annual_interest_rate, status: PASS, value: 12.5, violation: null}或{status: FAIL, violation: format_mismatch, expected: percentage_2dp, actual: 12.500%}。Consistency Orchestration Layer一致性编排层这就是OpenAI Agent SDK的主舞台。它接收来自契约引擎的多个模型的验证报告而非原始文本并执行仲裁逻辑。SDK的Agent对象被配置为一个“仲裁Agent”其System Prompt明确限定角色“你是一个中立的语义一致性仲裁员。你只能基于输入的结构化验证报告JSON格式进行判断。禁止生成任何未在报告中出现的字段或数值。你的输出必须是严格的JSON{‘verdict’: ‘一致’|‘冲突’|‘需人工’ ‘conflict_fields’: [‘field1’, ‘field2’], ‘confidence_score’: 0.0-1.0}”。关键技巧在于我们为Agent配备了两个专用Toolcompare_field_values比较两个报告中同一字段的value和status和check_context_consistency检查跨字段的上下文依赖是否同时满足。Agent不看原文只调用Tool处理结构化数据彻底规避了LLM的幻觉干扰。Output Audit Layer输出与审计层最终仲裁结果连同完整的验证链路原始输入→归一化文本→各模型验证报告→Agent调用日志→最终判决被写入不可变的审计日志。日志采用W3C Trace Context标准每个决策都有唯一trace_id支持全链路回溯。这才是“Referee”区别于普通评测工具的核心——它不只告诉你“不一致”还告诉你“在哪一步、依据哪条契约、哪个模型违反了哪条规则”。2.3 为什么不用LangChain或LlamaIndex一次踩坑的实证有同事提议用LangChain的LLMChain封装一致性判断理由是“生态成熟”。我们做了AB测试用相同契约规则贷款利率范围测试100个样本。LangChain方案的误判率高达38%主要问题在两处一是其PromptTemplate对数字格式的鲁棒性差12.5%和0.125常被误判为不同值二是当契约含上下文依赖如“利率随期限变化”时Chain的Memory机制会把前序样本的上下文错误注入后续判断。而ADKAgent SDK组合的误判率稳定在2.1%主要来自NLU解析器对极罕见方言表述的漏识别。根本原因在于LangChain是“文本到文本”的管道而ADKAgent SDK是“文本→结构化验证→结构化决策”的闭环。前者在语义层面是黑箱后者每一步都可验证、可调试。这决定了它能否在金融、医疗等强合规场景落地。3. 核心细节解析ADK契约编写、Agent SDK配置与归一化实战3.1 ADK契约编写从模糊需求到可执行规则的三步转化法写好ADK契约是整个项目成败的基石。很多团队卡在这一步写出的契约要么太宽泛如“内容必须准确”要么太死板如“必须包含以下5个词”。我的经验是遵循“Context-Constraint-Check”三步法第一步锁定Context上下文锚点不要从字段名开始先问“这个字段在什么业务动作中被使用它的值会影响哪个下游决策” 例如“客户风险等级”字段Context是“信贷额度审批决策”。这意味着契约必须关联到“额度上限”和“利率浮动系数”两个下游字段。ADK契约中我们这样定义context_dependencyfield: customer_risk_level type: string constraints: allowed_values: [A, B, C, D] context_dependency: - field: credit_limit condition: risk_level_A_or_B requirement: gte(50000) # A/B级客户额度≥5万 - field: interest_rate_factor condition: risk_level_C_or_D requirement: gt(1.0) # C/D级客户利率上浮这里的risk_level_A_or_B不是字符串而是ADK内置的Context Resolver函数它会动态解析当前文本中customer_risk_level的值并触发对应校验。第二步定义Constraint约束类型ADK原生支持7种基础约束min,max,length,regex,allowed_values,required,format但真正的威力在于组合。例如对“合同签署日期”我们要求a) 是有效日期format: dateb) 不得早于公司成立日min: 2010-01-01c) 必须是工作日custom_validator: is_business_day。最后一条需要编写一个Python函数注册到ADKdef is_business_day(date_str: str) - bool: from datetime import datetime, timedelta date datetime.strptime(date_str, %Y-%m-%d) # 调用公司内部假期API或加载静态节假日列表 holidays get_company_holidays() return date.weekday() 5 and date.date() not in holidays # 在ADK初始化时注册 adk.register_validator(is_business_day, is_business_day)第三步设计Check校验反馈契约不仅要判断对错更要告诉开发者“错在哪”。ADK的violation_message支持Jinja2模板可动态注入信息violation_message: 日期{{ value }}无效{{ reason }}。请确保是YYYY-MM-DD格式且为工作日。参考公司2024年节假日表{{ holidays_url }}holidays_url是ADK运行时注入的环境变量。这样当校验失败时开发者看到的不是冰冷的format_mismatch而是带链接的 actionable error message。提示契约文件必须版本化管理。我们在Git中为每个业务线建立/adk-contracts/loans/v1.2.0.yaml目录每次变更需附带CHANGELOG.md说明影响范围如“v1.2.0新增利率上下文依赖影响所有贷款审批流”。ADK SDK支持加载指定版本契约避免线上服务因契约更新意外中断。3.2 OpenAI Agent SDK配置如何让Agent真正“只看报告不看原文”Agent SDK的默认配置会让Agent过度依赖System Prompt导致它偷偷“脑补”原文内容。必须通过三重硬性隔离来杜绝第一重输入过滤Input Sanitization在Agent初始化前对所有传入的messages进行预处理。我们编写了一个sanitize_input函数def sanitize_input(messages: List[Dict]) - List[Dict]: for msg in messages: if msg[role] user: # 只允许传入结构化JSON报告禁止任何原始文本 try: report json.loads(msg[content]) # 验证report结构符合预设schema validate_report_schema(report) msg[content] json.dumps(report, ensure_asciiFalse) except (json.JSONDecodeError, ValidationError): raise ValueError(User input must be valid JSON validation report) return messages这个函数作为Middleware注入Agent的invoke流程任何不符合{model_name: ..., field_reports: [...]}结构的输入都会被拦截。第二重Tool权限控制Tool ScopingAgent可用的Tool必须严格限定。我们只注册两个Toolcompare_field_values(field_name: str, report_a: dict, report_b: dict) - dict输入两个报告的field_reports子项输出{“match”: true/false, “difference”: “...”}。check_context_consistency(context_rules: list, all_reports: list) - dict输入契约中的context_dependency规则和所有报告输出跨字段一致性结论。关键点在于这两个Tool的参数类型都是dict且文档中明确标注“输入必须是ADK验证报告的子集禁止传入原始文本字符串”。Agent SDK的Tool注册机制会强制类型检查从源头堵死文本注入。第三重输出Schema强制Output GuardrailsAgent的最终输出必须是机器可解析的JSON。我们利用SDK的output_parser功能定义一个严格Schemafrom pydantic import BaseModel, Field class ArbitrationResult(BaseModel): verdict: Literal[一致, 冲突, 需人工] Field(..., description仲裁结论) conflict_fields: List[str] Field(default_factorylist, description冲突字段列表) confidence_score: float Field(ge0.0, le1.0, description置信度0-1) trace_id: str Field(..., description本次仲裁的唯一追踪ID) # 在Agent初始化时绑定 agent Agent( ..., output_parserJsonOutputParser(pydantic_objectArbitrationResult) )如果Agent试图输出我认为模型A更可靠这样的自然语言JsonOutputParser会直接抛出异常并重试确保输出100%结构化。注意Agent的max_iterations必须设为一个较小值我们设为3。因为每一次迭代都意味着一次LLM调用而我们的仲裁逻辑是确定性的基于结构化数据比对理论上1次迭代就应完成。设为3是为了容忍网络抖动导致的首次调用失败但绝不能设为10否则会诱发Agent陷入无意义的自我辩论循环。3.3 输入归一化层那些被忽略的“脏数据”才是最大敌人归一化层代码不足50行却是线上故障率最高的模块。我整理了生产环境中最常见的7类“归一化陷阱”以及对应的解决方案归一化陷阱类型典型表现实测发生频率解决方案Unicode变体混淆“中文引号 vs英文引号·中间点 vs.英文句点28%使用unicodedata.normalize(NFKC, text)标准化所有Unicode字符数字格式漂移1,000.00vs1000.00vs1 000,00欧洲格式22%正则预处理re.sub(r[^\d.-], , text)粗筛再用locale.atof()按区域设置解析单位歧义100MBvs100mbvs100 MBytes15%构建单位映射字典{mb: MB, mbytes: MB, 兆字节: MB}统一转为标准缩写时间表达冗余2024年03月15日vs2024-03-15vsMar 15, 202412%使用dateutil.parser.parse()解析为datetime对象再格式化为%Y-%m-%d列表项标记混乱- item1vs• item1vs1. item19%统一替换为- item1并删除序号re.sub(r^\d\.\s*, -, line)HTML/XML残留p内容/pvsamp;8%使用html.unescape()re.sub(r[^], , text)双重清理OCR识别错误O字母O被误认为0数字零l小写L被误认为16%启用ocr_correction开关对疑似数字字段应用re.sub(r[Oo], 0, field_value)等规则最关键的经验是归一化函数必须是幂等的Idempotent。即normalize(normalize(text)) normalize(text)。我们在线上部署时会对每个归一化步骤添加assert断言一旦发现非幂等行为如某次清理会把100%变成100再次清理又变成100立即告警并回滚。这保证了即使上游模型多次重试归一化结果也绝对一致。4. 实操过程从零搭建一个贷款审批语义仲裁器4.1 环境准备与依赖安装精简到极致的必要组件这个项目对环境的要求非常苛刻既要支持ADK的YAML契约解析又要兼容OpenAI Agent SDK的异步调用还不能引入重量级依赖如PyTorch拖慢启动速度。经过17次Docker镜像构建测试我们锁定了以下最小可行依赖集# requirements.txt google-adk0.8.2 # Google官方ADK SDK注意必须0.8.x0.9有breaking change openai1.35.0 # Agent SDK要求的OpenAI Python库版本 pydantic2.7.1 # 用于Output Schema验证 spacy3.7.4 # ADK NLU解析器依赖必须3.7.x3.8有tokenization bug en_core_web_sm3.7.1 # spaCy英文模型轻量且足够 jinja23.1.4 # 用于violation_message模板 python-dateutil2.8.2 # 时间解析安装命令极其简单pip install -r requirements.txt python -m spacy download en_core_web_sm注意google-adk包目前未上PyPI需从Google Cloud Artifact Registry安装pip install --index-url https://us-central1-python.pkg.dev/your-project-id/adk-repo/simple/ google-adkyour-project-id需替换为你在Google Cloud中创建的ADK项目ID。这是最容易卡住新手的一步——如果没配置好Artifact Registry的认证pip install会报403错误。解决方案是先运行gcloud auth login再gcloud artifacts repositories add-auth-config ...。4.2 编写第一个ADK契约贷款年利率校验loans_rate.adk.yaml我们以最核心的“贷款年利率”字段为例展示从需求到可运行契约的完整过程。业务需求原文是“年利率必须是0-36之间的数字保留两位小数且当贷款期限超过12个月时利率不得高于24%”。# loans_rate.adk.yaml version: 1.0 description: 贷款年利率语义契约用于审批流仲裁 fields: - field: annual_interest_rate type: number description: 年化利率单位为百分比 constraints: min: 0.0 max: 36.0 format: percentage_2dp context_dependency: - field: loan_term_months condition: gt(12) requirement: lt(24.0) violation_message: 贷款期限{{ loan_term_months }}个月 12个月年利率{{ value }}% 违反上限24%规定 violation_message: 年利率{{ value }}% 无效{{ reason }}。请确保是0.00-36.00之间的数字且保留两位小数。关键细节说明format: percentage_2dp是ADK内置格式它会自动匹配12.50%、0.125、12.5等所有常见表示并统一转换为12.50float。context_dependency中的loan_term_months字段ADK会自动从同一份输入文本中提取。无需在契约中定义它只要文本里有“期限18个月”、“term: 18”等表述ADK的NLU就能识别。violation_message中的{{ loan_term_months }}是ADK的动态变量注入它会把从文本中提取的loan_term_months值实时填入。4.3 初始化ADK引擎与验证函数# adk_engine.py from google.adk import AdkEngine from google.adk.models import ValidationReport # 初始化ADK引擎加载契约 adk AdkEngine( contract_path./loans_rate.adk.yaml, nlu_modelen_core_web_sm, # 指定spaCy模型 cache_enabledTrue, # 启用NLU结果缓存提升性能 ) def validate_loan_input(text: str) - ValidationReport: 对贷款审批输入文本执行语义验证 返回结构化报告供Agent SDK消费 try: # 执行归一化调用3.3节的sanitize_text函数 normalized_text sanitize_text(text) # ADK执行验证 report adk.validate(normalized_text) return report except Exception as e: # 记录详细错误但返回兜底报告避免中断流程 logger.error(fADK validation failed for {text[:50]}...: {e}) return ValidationReport( statusERROR, errors[fADK internal error: {str(e)}], field_reports[] )4.4 构建OpenAI Agent SDK仲裁Agent# referee_agent.py from openai import OpenAI from openai.types.chat import ChatCompletionMessageToolCall from pydantic import BaseModel, Field import json client OpenAI(api_keyyour-openai-key) # 生产环境请使用环境变量 class ArbitrationResult(BaseModel): verdict: str Field(..., description仲裁结论一致/冲突/需人工) conflict_fields: list[str] Field(default_factorylist) confidence_score: float Field(ge0.0, le1.0) trace_id: str Field(..., description追踪ID) # 定义Tool函数 def compare_field_values(field_name: str, report_a: dict, report_b: dict) - dict: 比较两个模型对同一字段的验证结果 val_a report_a.get(value) val_b report_b.get(value) status_a report_a.get(status, UNKNOWN) status_b report_b.get(status, UNKNOWN) if status_a ! PASS or status_b ! PASS: return {match: False, reason: f至少一个模型未通过校验A{status_a}, B{status_b}} # 数值比较考虑浮点误差 if isinstance(val_a, (int, float)) and isinstance(val_b, (int, float)): if abs(val_a - val_b) 0.01: return {match: True, reason: 数值差异在容差范围内} else: return {match: False, reason: f数值差异过大{val_a} vs {val_b}} # 字符串比较 if str(val_a) str(val_b): return {match: True, reason: 字符串完全一致} else: return {match: False, reason: f字符串不一致{val_a} vs {val_b}} # Agent初始化 referee_agent client.beta.assistants.create( nameLoan Semantic Referee, instructions你是一个中立的语义一致性仲裁员。你只能基于输入的结构化验证报告JSON格式进行判断。禁止生成任何未在报告中出现的字段或数值。, modelgpt-4-turbo, tools[ { type: function, function: { name: compare_field_values, description: 比较两个模型对同一字段的验证结果, parameters: { type: object, properties: { field_name: {type: string}, report_a: {type: object}, report_b: {type: object} }, required: [field_name, report_a, report_b] } } } ], response_format{type: json_object} # 强制JSON输出 ) # 执行仲裁的主函数 def arbitrate_two_models(model_a_report: dict, model_b_report: dict, trace_id: str) - ArbitrationResult: 对两个模型的验证报告执行仲裁 # 构建用户消息只包含结构化数据 user_message { model_a: model_a_report, model_b: model_b_report, trace_id: trace_id } # 创建Thread并发送消息 thread client.beta.threads.create( messages[ { role: user, content: json.dumps(user_message, ensure_asciiFalse) } ] ) # 运行Agent run client.beta.threads.runs.create_and_poll( thread_idthread.id, assistant_idreferee_agent.id, timeout30 ) # 获取最终消息 messages client.beta.threads.messages.list(thread_idthread.id) last_message messages.data[0] # 解析JSON输出 try: result_dict json.loads(last_message.content[0].text.value) return ArbitrationResult(**result_dict) except (json.JSONDecodeError, ValidationError) as e: logger.error(fAgent output parse failed: {e}) return ArbitrationResult( verdict需人工, conflict_fields[], confidence_score0.0, trace_idtrace_id )4.5 端到端集成测试用真实贷款审批案例验证我们选取了一个真实的、曾引发客诉的案例进行端到端测试原始输入文本“客户张三申请个人信用贷款金额50万元期限24个月年利率12.5%。根据风控模型其信用等级为B级。”模型ARAG检索输出“年利率12.50%”模型B微调Llama3输出“客户适用年化利率为0.125”执行步骤归一化层处理模型A输出 →{annual_interest_rate: 12.50}模型B输出 →{annual_interest_rate: 12.5}0.125被ADK的percentage_2dp格式自动转换ADK验证两者status均为PASSvalue分别为12.50和12.5Agent SDK仲裁调用compare_field_values(annual_interest_rate, report_a, report_b)Tool返回{match: True, reason: 数值差异在容差范围内}Agent最终输出{ verdict: 一致, conflict_fields: [], confidence_score: 0.98, trace_id: trace-abc123 }结果分析这个案例在旧系统中会被标记为“冲突”因为12.50%≠0.125导致流程中断。而Referee系统正确识别出二者在语义上等价并给出高置信度判决。整个仲裁耗时平均为1.2秒P95其中ADK验证占0.3秒Agent调用占0.9秒。这证明了架构设计的有效性ADK承担了最耗时的NLU解析Agent只做轻量级结构化决策。5. 常见问题与排查技巧实录线上踩过的12个坑5.1 ADK相关问题速查表问题现象根本原因排查命令/方法解决方案ADK验证始终返回status: ERROR无具体错误信息nlu_model路径错误或spaCy模型未下载python -c import spacy; print(spacy.load(en_core_web_sm))运行python -m spacy download en_core_web_sm确认模型路径在spacy.info(en_core_web_sm)[path]context_dependency规则从未触发输入文本中缺少能被NLU识别的上下文字段关键词adk.debug_parse(text)查看NLU提取的全部实体在文本中显式加入关键词如“贷款期限24个月”而非“两年期”format: date校验对2024/03/15失败ADK默认只识别-分隔的日期不识别/adk.set_date_formats([%Y-%m-%d, %Y/%m/%d])在ADK初始化后调用此方法扩展日期格式契约中allowed_values对大小写敏感但业务要求不敏感ADK默认字符串比较是区分大小写的constraints: {allowed_values: [A,B], case_insensitive: true}在allowed_values同级添加case_insensitive: true字段5.2 Agent SDK相关问题速查表问题现象根本原因排查命令/方法解决方案Agent反复调用同一个Tool陷入死循环Tool函数返回了非JSON或格式错误的字符串print(tool_response)在Tool函数末尾添加日志确保Tool函数100%返回dict且json.dumps()可序列化response_format{type: json_object}不生效仍收到Markdown格式输出Assistant创建时未指定response_format或使用了旧版SDKassistant client.beta.assistants.retrieve(assistant_id)查看返回的response_format字段重新创建Assistant确保response_format参数传递正确SDK版本≥1.35.0max_iterations3时Agent在第2次迭代就返回incomplete状态输入的结构化报告JSON过大10KB触发了OpenAI的token限制len(json.dumps(user_message).encode(utf-8))计算字节数对field_reports做精简只传field_name,value,status去掉violation_message等大字段trace_id在最终输出中丢失Agent的System Prompt未声明trace_id是必需字段检查ArbitrationResultPydantic模型中trace_id: str Field(...)的...是否为...确保Field(...)中的...是Python的Ellipsis对象表示必填5.3 归一化层与集成问题问题现象根本原因排查命令/方法解决方案归一化后100MB变成100丢失了单位信息re.sub(r[^\d.-], , text)过于激进删掉了MBprint(repr(text))查看原始字符串的精确内容改用re.sub(r(?i)(\d.?\d*)\s*(mbdateutil.parser.parse()将2024-03-15解析为2024-03-15 00:00:00导致ADK校验失败ADK的date格式要求纯日期字符串不要时间部分parsed dateutil.parser.parse(text); parsed.date().isoformat()在归一化函数中对解析后的datetime对象调用.date().isoformat()线上服务偶发UnicodeEncodeError归一化函数返回了含BOM的UTF-8字符串而ADK内部处理异常text.encode(utf-8).decode(utf-8-sig)测试BOM清理在sanitize_text末尾添加text text.encode(utf-8).decode(utf-8-sig)5.4 实战避坑心得那些文档里不会写的教训心得一永远不要在契约中写“必须包含XX词”我最初为“违约责任”字段写了required_words: [赔偿, 解除]结果模型输出“甲方有权要求乙方赔偿损失并可单方解除合同”完美匹配。但上线后发现一个模型输出“甲方可以索赔并终止合作”因“终止合作”未被required_words覆盖被判为FAIL。后来改为semantic_required: [compensation, termination]并用spaCy的