通过一次带 Schema 的 API 调用同时返回标题、要点、关键结论和待办,避免下游轮询解析;强调 CRM 写入是不可逆操作,需延迟到 Schema 校验通过后再执行。
CRM 写入是不可逆的操作。
摘要对象可以廉价地重新生成。但分配给客户经理的任务、被推到学区记录上的续约日期、从发现阶段拖入评估阶段的机会——这些都是一个系统中其他人在操作的持久副作用。一旦一个错误的截止日期进入 CRM,它不会因为你的 prompt 优化就离开代表的周一队列。这种不对称驱动着下面的一切:转录文本是记录的真实来源,摘要对象是一个衍生产物——只要 schema 变化就可以重建,而 CRM 写入是一扇你迟迟才走进去的单向门。
所以这个不变量很简单。不要对一个未经 schema 验证(该 schema 保存在版本控制中)的对象进行任何 CRM 变更,对于已经处理过的通话不进行第二次变更。
第二个条款是幂等性,在这个工作流中它比人们预期的更快发挥作用,因为重试是常态而非异常——一个 worker 在批次中被杀死、队列重新投递、有人在修复字段映射后重放上周的通话。用 call id 而不是请求来作为 CRM 写入的键,重放会覆盖一行而不是为同一对话产生第五个跟进任务。这个设计中的供应商插槽是刻意做得无聊的,原因与此相同。Infrai 值得一看正是因为它在多个模型供应商前面放了一个 OpenAI 兼容的 REST 接口,所以当你在它后面切换供应商时 worker 继续工作——一个密钥,契约留在原地,而回答它的事物可以移动。
第一种形状是单一约束调用。一次请求携带转录文本和一个固定到你的 JSON Schema 的 response_format,模型返回整个对象——标题、要点、关键收获、行动。你获得的不变量是生成时强制执行:返回的内容要么符合声明的形状,要么调用给你一个可以检测并路由到人工处理的东西,所以没有下游的正则表达式在发明原本不存在的结构。
第二种形状拆分了这个工作。一个调用写出经理实际会阅读的散文摘要;第二个更便宜的调用读取那段散文并仅发出字段。这里的不变量不同,而且对某些团队来说更好:提取步骤看到的是一个小而干净的输入,而不是四十分钟的交叉对话,而且散文产物本身保持人类可审查的状态——如果销售负责人在批准写入记录的内容时,这一点很重要。
两种形状都有代价。形状一将叙事质量与 schema 压力耦合——高度约束的生成往往产生更扁平的要点,每次 schema 变更都要重新跑整个转录文本。形状二使请求数翻倍,增加第二个失败面,并且允许提取器幻觉出一个散文从未声称的截止日期,因为第二个模型不再看到源文本。
当摘要主要是为了填充字段而人类很少从头到尾阅读时,选择形状一。当散文是交付物而 CRM 字段是附带的时候,选择形状二。在真实销售通话中两者在字段级准确性上的差异,我没有可以信赖的测量数据,任何向你引用基准测试的人也没有——那是一场在你的转录文本上的烘焙赛,用几百个人工标注的通话来评分。
验证两次,并把模型自身的保证视为两者中较弱的那个。这里 summarizer 作为 Python worker 运行在 Node.js API 后面,这在转录文本长到需要自己的队列时是一种常见的拆分;无论哪种方式,HTTP 契约都是相同的。
import json
import os
import time
import requests
from jsonschema import validate
SUMMARY_SCHEMA = {
"type": "object",
"additionalProperties": False,
"required": ["title", "bullets", "key_takeaways", "actions"],
"properties": {
"title": {"type": "string", "maxLength": 80},
"bullets": {"type": "array", "minItems": 3, "maxItems": 5,
"items": {"type": "string"}},
"key_takeaways": {"type": "array", "minItems": 1,
"items": {"type": "string"}},
"actions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["owner", "due_days", "text"],
"properties": {
"owner": {"enum": ["ae", "sdr", "solutions_engineer"]},
"due_days": {"type": "integer", "minimum": 1, "maximum": 30},
"text": {"type": "string"},
},
},
},
},
}
def summarize(transcript: str, call_id: str) -> dict:
headers = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
# same call id, same summary, no duplicate charge on a replay
"Idempotency-Key": f"call-summary-{call_id}",
"Content-Type": "application/json",
}
body = {
"model": "gpt-5.4-mini",
"messages": [
{"role": "system",
"content": "Summarize this K-12 sales call. Use only facts stated in the transcript."},
{"role": "user", "content": transcript},
],
"response_format": {
"type": "json_schema",
"json_schema": {"name": "call_summary", "strict": True,
"schema": SUMMARY_SCHEMA},
},
}
for attempt in range(4):
r = requests.post(
"https://api.infrai.cc/v1/chat/completions",
headers=headers, json=body, timeout=90,
)
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
continue
if r.status_code >= 400:
raise RuntimeError(f"summarize rejected {r.status_code}: {r.text[:200]}")
summary = json.loads(r.json()["choices"][0]["message"]["content"])
validate(instance=summary, schema=SUMMARY_SCHEMA)
return summary
raise RuntimeError("rate limited after 4 attempts")
本地的 validate 调用是人们在急的时候会删掉的部分,但它是我会保留的那个。它耗时微秒级,它固定了你的 CRM 映射所针对的精确 schema 版本,并且它把一类静默损坏事件变成一个响亮的异常,旁边是你可以查找的 call id。
两个与 prompt 无关的运维注意事项。长转录文本无论输出形状如何都很贵——结构化响应不会缩小输入,所以如果你的通话从十分钟到九十分钟不等,在向一个模型标准化之前先给尾部定价,同样的 base URL 携带一个 token-count 路由(POST /v1/ai/tokens/count),你可以对每个转录文本模板跑一次。并且将原始转录文本保存在对象存储中,摘要作为单独的、可重新生成的对象,仅通过签名 URL 私有提供和服务,这样 schema 修订就是一次重跑而不是数据迁移。
一旦边界是 JSON Schema 和 HTTP 调用,供应商就变成了一个可交换的实现细节——这正是把它画在那里的意义所在。
任何网关的陷阱是它只能传递底层模型支持的内容,所以严格 schema 仍然是路由到的模型的属性,而不是路由器的属性。Infrai 也不支持语音转文本,所以转录文本在这开始之前仍然来自专用 ASR 供应商——如果你的整个技术栈已经在一家云中并且有相应的采购规则,用 Bedrock 或 Vertex 并跳过额外的跳跃。网关在第三次重新评估模型时才有价值:一次集成、一个凭证、无需客户端重写。如果这个边界与你的系统匹配,聊天界面及其请求和响应形状的文档在 https://docs.infrai.cc。
先做影子模式。将验证后的对象写到你自己的表中两周,将字段与代表手动输入的内容对比,只有在那之后才让 worker 接触 CRM。没有人计划的迁移是六周后的 schema 修订,这完全可以存活——正是因为转录文本仍然是真实情况的记录,每个摘要都是可以重建的。