双语问题:为什么类型安全会在提示词边界失效
你的代码库中有两种语言,但只有一种拥有编译器。一种是你的团队编写的严格类型的代码 —— 开启了 strict: true 的 TypeScript、CI 中运行 mypy 的 Python、强制返回值的 Go —— 另一种则是 Prompt:一个模板化的字符串,经过拼接后发送给远程模型,并返回另一个运行时希望能成功解析的字符串。在这两个区域之间,类型系统就成了瞎子。IDE 不会高亮任何内容。编译器不会发出任何报警。而那些凭借“但它通过了类型检查”就发布功能的团队,实际上将核心契约放在了契约检查器看不见的地方。
这个接缝伪装得很好。从外部看,它就像一个函数调用:generate(input: UserQuery): Promise<AgentResponse>。函数签名诚实地反映了输入和输出。而虚假的部分发生在调用点和响应之间:输入被插值到通过字符串引用字段名的 Prompt 模板中;模型被要求生成一个符合该 Prompt 内部以自然语言描述的 Schema 的 JSON 对象;响应作为一个字符串返回并交给解析器;最后解析器返回类型系统终于能再次看到的内容。两端的每一个类型化表达式都在对 中间一个完全没有静态保证的区域做出断言。
这并非理论上的担忧。各团队报告称,在生产环境中,朴素的结构化输出基准 Schema 失败率为 10–20%,而且失败往往集中在那些你最无法承受无声丢弃的输入上 —— 长上下文、深层工具链、边缘情况用户。类型系统提供了一种虚假的正确感,直到格式错误的 JSON 返回且运行时将其吞下为止。
编译器的最后一公里
静态类型系统之所以奏效,是因为它们能追踪数据在形状可见的表达式中的流转。在任何 LLM 应用中,它们都会在三个地方失去踪迹,而这三个地方都处于类型化调用点和类型化响应对象之间的“禁飞区”。
插值接缝。 Prompt 模板是一个带有命名占位符的字符串:"Given the user query {{query}} and their plan {{plan}}, decide if they qualify."。模板引擎通过字符串替换来填充占位符。类型系统将模板视为 string。它将输入视为 { query: string; plan: string }。它无法察觉到,如果你在代码库各处将 plan 重命名为 tier,模板仍会请求一个不再存在的字段,因为模板对 plan 的引用只是字符串字面量中的一个子字符串,任何静态分析都不会去触及它。
这种模式最终会反噬每一个 TypeScript LLM 代码库:工程师重命名了一个领域模型字段,IDE 自信地重写了每一个类型化引用,而 Prompt 模板却默默地发出 "Given the user query {{plan}} and their plan undefined",因为替换失败了,模板引擎返回了未填充的占位符。模型 现在正在根据一个 undefined 的 plan 进行推理。评估(eval)现在已被污染。TypeScript 编译器以状态码 0 正常退出。
Schema 即散文接缝。 每一个结构化输出调用都会向模型发送一个 JSON Schema。大多数团队通过 Zod 或 Pydantic 定义生成该 Schema,然后在每个属性上添加 description 字段来指导模型。从类型系统的角度来看,描述只是一个字符串字面量。从模型的角度来看,描述就是该字段的 Prompt。在代码中重命名一个字段而不更新其代码中的描述,会制造出一种隐形的矛盾,模型最终会以一种无人预料的方式来化解这种矛盾。Schema 和散文是独立版本化的,因为类型系统认为它们是同一个东西。
输出字符串接缝。 模型的响应以字符串形式到达。在下游的某个地方,代码执行 JSON.parse(response) 并将结果转换为预期类型。这种类型转换是类型系统同意参与的一个谎言。运行时解析器可能产生了一个缺少必需字段的对象,或者某个字段的值违反了仅存在于两层之上的 Zod 校验中的约束。直到类型转换触及一个实际失败的属性访问之前,类型检查器都会对该转换感到满意。直到那一刻。
缺乏纪律时会发生什么
当这种“边缘类型安全、中间 Prompt”的架构在边界缺乏明确纪律的情况下发布时,其失败模式遵循一种可预测的分类。
隐性 Schema 漂移。 团队在代码中重命名了一个枚举值(tier: "free" | "pro" | "enterprise" 变为 tier: "free" | "pro" | "business"),更新了验证器,并发布了更改。由于 few-shot 字符串是硬编码在行内的,Prompt 模板在 few-shot 示例中仍指向 "enterprise"。模型在处理该意图时继续在输出中生成 "enterprise"。验证器现在拒绝了 8% 的输出,重试逻辑默默承担了成本。两周内没人注意到这一点,因为仪表盘报告“Schema 通过率为 92%”,这被视为一个稳定数值而非一次退化。
工具参数腐化。 团队向工具的参数 Schema 添加了一个新字段并更新了类型化签名。由于没人意识到自然语言描述其实就是参数列表,命名工具参数的 Prompt 模板并未更新。模型继续使用旧的形状调用工具。运行时会无声地强制转换或丢弃缺失的字段。工程团队报告称,这类失败 —— 格式错误的 JSON、缺失的字段、错误的类型 —— 产生的生产环境故障比幻觉还要多。这不是模型的问题,而是类型系统本应防止却未能防止的契约同步问题。
往返不可见性。 符合 Schema 的响应在语义上可能是空的。曾经发布过一个情感分类器,其中“每条记录都是有效的 JSON、正确的类型、正确的枚举” —— 且对包括胡言乱语在内的每项输入的置信度都是 0.99,因为模型已经坍缩到了一个 Schema 乐于接受的恒定输出。验证器说“OK”。面向用户的功能说“每项输入都是高置信度的积极情感”。类型系统对此没有任何意见:线缆上的字节与类型定义完全匹配。
描述性文字腐化。 Schema 字段上的 description 最初是准确的。经历了三次产品变更后,描述仍然写着“用户账户等级(free, pro, or enterprise)”,而该字段现在已经接受 "business" 了。模型在每次调用中都在阅读陈旧的指令。类型系 统看不见这个矛盾,因为它在字符串内部。代码评审人员在 PR 审查中也无法发现,因为 Diff 看起来很整洁:只有枚举字面量发生了变化。
弥合差距的准则
修复方案不是更智能的编译器 —— 而是编译器无法强制执行,但团队可以执行的一套契约。三项准则协同运作,可以找回类型系统曾经提供的大部分功能。
以 Schema 作为单一事实来源。 在类型化语言中定义一次形状(Shape),并根据该定义生成所有下游内容。发送给模型的 JSON schema、嵌入在 Prompt 中的文案描述、运行时解析器,以及代码其他部分所使用的类型 —— 这四者都源自同一个 Zod 或 Pydantic 对象。像 BAML 这样的工具更进一步,将 Schema 变成一种独立的 DSL,并编译成多种语言的类型化客户端。但你不需要为了开始而引入新的 DSL;你只需要一个构建步骤,来捕捉描述字符串和字段名称不一致的时刻。
最简单的版本:每个 Agent 一个文件,同时导出 Zod schema 和 Prompt 模板,并配有一个单元测试,该测试将每个占位符与示例输入进行插值,并断言结果中不包含字面量 {{ 或 undefined 子字符串。这个测试是 Prompt 边界所能获得的最接近编译器的东西,它能在引入 bug 的 PR 中捕捉到“重命名但未更新模板”的错误。
将往返评估(Round-trip eval)作为 CI 门禁。 标准的评估模式是对模型输出的质量进行评分。而往返评估则是对契约进行评分:获取采样输入,运行完整流水线,解析输出,并断言解析后的对象不仅满足 Schema,还满足 Schema 无法表达的语义不变性 —— 例如分布在不同输入间是否变化、置信度不是常量、证据字段确实逐字出现在输入中、枚举值没有漂向已废弃的类别。将此作为 CI 门禁,在每次 Prompt 变更、每次 Schema 变更以及每次模型升级时触发。
评估集必须足够多样化,以暴露模型崩溃(mode collapse),因此要包含对抗性输入,其唯一目的是检测模型是否已停止区分差异。一个廉价的版本:断言 200 个生产输入样本在每个枚举字段中至少产生三个不同的输出。失败意味着某些东西正在崩溃 —— 可能是过时的 Prompt、强制默认值或模型退化 —— 而门禁会在用户发现之前拦截。
针对边界规则的 Prompt Linter。 类型系统无法强制执行的衔接规则,仍然可以通过了解 Prompt 模板的静态 Linter 来检查。最低限度的规则集:模板中的每个占位符必须对应于类型化输入对象上的一个字段;Few-shot 示例中提到的每个枚举值必须存在于 Schema 中;文案描述中引用的每个字段必须存在于 Schema 中;诸如“Guardrails 先于 Persona 先于 Tools”之类的排序不变性在编辑过程中保持不变。
构建这个 Linter 在任何语言中都只需要一个周末的工作量;其价值在于它运行在 pre-commit 和每个 PR 中,暴露出语言编译器在结构上无法察觉的边界违规。投入很小,但不具备它的代价是一次生产事故加上一份写着“我们本该在 CI 中检查这一点”的复盘报告。
架构上的感悟
类型系统在 Prompt 处停止,这并不是 TypeScript 或 Python 的缺陷。这是架构的一种属性:LLM 是一个消耗自然语言并输出 文本的远程进程,类型检查器无法从一个输出分布采样自概率模型的远程进程中提取静态保证。你所能拥有的是在字节跨越的每个边界上执行的契约 —— 填充模板的输入边界、文案描述离开代码库的 Schema 边界,以及响应被解析回来的输出边界。
构建生产级 AI 系统的团队将 Prompt 视为一种类型化产物,即便语言本身并不提供帮助。他们将 Schema、模板和文案描述保存在同一个文件中,并由同一个源生成。他们运行往返评估来对契约(而不仅仅是质量)进行评分。他们编写 Linter 来检查编译器看不见的规则。这些准则没有一项是华丽的,也不会出现在基准测试中。它们体现在事故的消失中。
“双语言问题”不会消失 —— 从定义上讲,LLM 是一种与调用它的代码完全不同的语言。改变的是,你的代码库是假装这个衔接点不存在,还是将其视为具有自身工具、测试和负责人的头等工程表面。第一类团队将不断被类型检查器本应捕获但未捕获的静默失败所惊扰。第二类团队将发现,只要经过适当的插桩,Prompt 边界就只是另一个契约 —— 虽然陌生且遥远,但其可强制执行程度不亚于那些已经行之有效的契约。
- https://johnchildseddy.medium.com/typescript-llms-lessons-learned-from-9-months-in-production-4910485e3272
- https://collinwilkins.com/articles/structured-output
- https://github.com/BoundaryML/baml
- https://python.useinstructor.com/
- https://dev.to/whoffagents/openai-structured-outputs-vs-zod-which-to-use-for-llm-response-validation-in-2026-366m
- https://medium.com/@Micheal-Lanham/stop-blaming-the-llm-json-schema-is-the-cheapest-fix-for-flaky-ai-agents-00ebcecefff8
- https://neon.com/blog/prompt-engineering-developer-discipline
- https://www.promptfoo.dev/docs/guides/evaluate-json/
- https://dev.to/pockit_tools/llm-structured-output-in-2026-stop-parsing-json-with-regex-and-do-it-right-34pk
