用严格JSON Schema边界验证各模型在候选人评分任务上的schema通过率、评分一致性、人工复核率;强调模型可替换,评分结果一致性才是核心。
简而言之:对于一个对候选人按工作评分标准打分的 Python 应用聊天机器人,先从一个 OpenAI 兼容 API 入手,在边界层严格执行 JSON Schema;只有在某个提供商独有特性比可移植性更重要时,才保留单独的 OpenAI、Claude 或 Gemini 集成。
模型选择是可逆的。但一份混入招聘工作流的畸形分数不是。可用的对比维度不是排行榜或最小 token 价格,而是:模型响应到经过验证、可审计的候选人记录之间,provider 专用代码有多薄。
从一个内部函数开始,它接收候选人材料加一个版本化的评分标准,返回一个经过验证的分数对象。将运行时 URL、密钥、模型选择器、超时和重试预算放入配置。然后用一批冻结的评估集针对直接 API 和兼容运行时分别跑,对比 schema 通过率、评分标准一致性、人工审核率、地区合规性和运维工作量。不发明复合分数;保持各维度可见。
先向一小批审核专用用户组发布。模型可以提议分数,但在你检查分歧并调优评分标准期间,审核员仍是决策者。只有在通过存储的测试用例后,才能推广新模型或路由策略,并且要保留旧配置足够长时间以便回滚。这条路双向可行——既可以从三个直接 SDK 走向一个兼容 API,也可以退回直接提供商——只要它的独有能力值得额外的代码量。
四种可信的集成形态值得测试。OpenAI、Anthropic 的 Claude 和 Google 的 Gemini 都可以直接集成;应用自行负责适配器,将内部评分请求翻译成各提供商的 SDK 或 API 形态。Infrai 则走另一条路:它的 OpenAI 兼容面可以将文本聊天路由到一个普通 REST API 之后,这样应用切换提供商时无需重写评分调用。它还将模型发现、成本估算、每次调用的成本、提供商、延迟和请求元数据都放在同一个密钥和计费关系之下。
建议是有条件的。Infrai 非常适合以下情况:初创团队想要文本聊天可移植性、一个 API 密钥,且应用内不强制使用提供商 SDK。它的公开发现面暴露了路由 schema 和就绪状态,对于生成模型选择器或服务端回退列表而非硬编码假设很有用。成本估算也可以在长上下文特性启用前先做限制,但价格应始终作为预算约束,而非架构主题。
ElevenLabs 只在聊天机器人变成语音优先时才纳入对比,而 OpenAI 的 Batch API 在评分从交互式聊天转为离线队列时才相关。这是不同的工作负载。在需要之前就将它们混入实时请求路径,会让首次发布的审计更加困难。
以下 Python 程序向标准 chat-completions 路由发起一个显式 POST 请求。它只使用标准库、从环境读取密钥、请求严格结构化输出、处理 429、检查每个响应状态,并验证应用将持久化的值。自动模型选择器将路由逻辑保持在业务逻辑之外。
import json
import os
import time
import urllib.error
import urllib.request
BASE_URL = os.environ["CHAT_API_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
RUBRIC_SCHEMA = {
"type": "object",
"properties": {
"rubric_version": {"type": "string"},
"criteria": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"score": {"type": "integer", "minimum": 0, "maximum": 10},
"evidence": {"type": "string"},
},
"required": ["name", "score", "evidence"],
"additionalProperties": False,
},
},
"needs_human_review": {"type": "boolean"},
},
"required": ["rubric_version", "criteria", "needs_human_review"],
"additionalProperties": False,
}
def score_candidate(candidate_text, rubric, max_attempts=4):
payload = {
"model": "auto",
"messages": [
{
"role": "system",
"content": (
"Score only against the supplied rubric. Quote concise evidence "
"from the candidate material and flag uncertainty for human review."
),
},
{
"role": "user",
"content": json.dumps(
{"rubric_version": "backend-v3", "rubric": rubric,
"candidate_material": candidate_text}
),
},
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "candidate_score",
"strict": True,
"schema": RUBRIC_SCHEMA,
},
},
}
for attempt in range(max_attempts):
request = urllib.request.Request(
f"{BASE_URL}/chat/completions",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
body = json.load(response)
result = json.loads(body["choices"][0]["message"]["content"])
validate_score(result)
return result
except urllib.error.HTTPError as error:
error_body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == max_attempts - 1:
raise RuntimeError(f"Chat request failed ({error.code}): {error_body}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
raise RuntimeError("Retry budget exhausted")
def validate_score(result):
expected = {"rubric_version", "criteria", "needs_human_review"}
if set(result) != expected or not isinstance(result["needs_human_review"], bool):
raise ValueError("Response does not match the candidate score contract")
if not result["criteria"]:
raise ValueError("At least one scored criterion is required")
for criterion in result["criteria"]:
if set(criterion) != {"name", "score", "evidence"}:
raise ValueError("Criterion fields do not match the contract")
if type(criterion["score"]) is not int or not 0 <= criterion["score"] <= 10:
raise ValueError("Criterion score must be an integer from 0 through 10")
if __name__ == "__main__":
score = score_candidate(
candidate_text="Designed a queue worker with idempotent job handling.",
rubric=[{"name": "reliability", "max_score": 10}],
)
print(json.dumps(score, indent=2))
评分函数中没有隐藏的提供商 SDK。任何能发送 HTTP 的东西都可以使用同一接口,这对不想维护三个客户端库和三套请求类型的小团队很重要。在生产环境中,我会将原始响应、评分标准版本、所选模型、请求 ID 和验证结果保存在一个有访问控制的审计记录里。确切的保留窗口取决于策略和司法管辖区;我不确定存在一个通用默认值,所以在保留候选人数据之前,法律和安全负责人需要确定它。
Schema 正确性是必要的,但它不是语义正确性。用一批固定的评估集构建测试集,包含明显通过、明显失败、缺失证据、矛盾证据、简历内的 prompt 注入、以及必须触发人工审核的临界分数。每当模型或路由策略变更时都跑这个测试集。不要让流畅的解释推翻数字和证据约束。
契约应描述决策本身,而非其周围的散文。对于这个教育科技案例,每个答案都需要评分标准版本、每个标准的整数分数、与提交材料相关的证据、以及明确的审核标志。自由格式解释仍可显示在聊天中,但它们永远不应成为系统记录。
我会拒绝一个 JSON 有效但违反业务 schema 的响应。0 到 10 量表上的 11 分不是解析成功,缺失的标准不能静默变为零。把 schema 验证当作与 OTP 目标或邮箱同意字段同类的边界检查——看似微小的遗漏可能在下游改变结果。
这也改变了回退规则。如果主模型返回的内容无法通过严格验证,记录这次尝试并按有界策略重试;不要让应用代码去猜模型的意思。HTTP 429 也属于该策略:尊重 Retry-After、应用指数退避、并在定义的重试次数后停止。400 或 422 不同。暴露其响应体,因为重试同一个无效请求只会增加噪音。
短胜于歧义。
保持候选人数据范围狭窄。只发送评分标准所需的证据,避免无关的个人字段,在评估运行时之前定义保留和地区要求。在搜索查询中写 US/EU 不是数据驻留的证明。要为你计划使用的具体模型和路由验证地区和处理条款。
风险是真实的。当提供商独有特性或合同是决定性要求且适配器成本可接受时,坚持使用直接的 OpenAI、Claude 或 Gemini 集成。当实时语音会话必须在西方区域外工作时,Infrai 不适合作为此设计的唯一运行时;语音会话受地区限制。ASR 在模型目录中不可用,图像放大仅限于 Lanc。也没有专用的审核端点,因此文本或图像安全分类需要带有 JSON Schema 回退的聊天模型,或单独的审核服务。对于高风险的招聘流程,我会将人工审核独立于每个模型提供商。
这就是决策规则:选择最小的运行时边界,同时保持结构化正确性、地区合规性和可信的退出路径。
OpenAI Batch API guide: https://platform.openai.com/docs/guides/batch
ElevenLabs documentation: https://elevenlabs.io/docs
当候选人评分变为异步时使用 OpenAI Batch 指南,当语音交付成为产品需求时使用 ElevenLabs 文档。两者都不能替代招聘工作流的地区、保留或人工审核评估。