LLM 时代谈编程语言和类型安全,很多人的第一反应是:这个话题不是已经聊了二十年吗?但过去一段时间的实际开发里,我越来越觉得这件事被严重低估了。现在的模型生成代码已经快到了让人麻木的程度,几秒钟就能输出一段能跑的 Python 函数或者 TypeScript 组件。可“能跑”和“正确”之间有一条很宽的沟,沟里全是类型错误、隐藏的 None、接口对不上、字段丢失、边界值没处理。类型安全正好是这条沟上最便宜、最自动化的一道桥。
这篇文章不打算从编译原理讲起,也不准备争论“动态类型好还是静态类型好”。我想聊的是更实操的问题:当 LLM 生成的代码开始大批量进入项目仓库,编程语言和类型系统到底能帮我们守住什么、守不住什么,以及怎么在日常工作流里把这道防线真正建起来。适合的读者包括正在用 Copilot、Cursor 或各种大模型写代码的开发者,也包括负责技术评审、CI 配置和项目规范的工程负责人。
1. LLM 时代,编程语言和类型安全为什么重新成为焦点
1.1 生成代码的门槛降低了,验证代码的瓶颈变高了
过去写代码,瓶颈在“写”:语法不熟、API 记不住、设计模式没见过。LLM 把这个瓶颈几乎移除了。现在你给模型一段需求,它能给你一个看起来结构完整的函数,甚至带注释、带类型标注、带测试用例。
但问题在于,模型输出的代码是基于概率生成的,不是基于证明生成的。它擅长“看起来像正确的代码”,而不是“被验证为正确的代码”。于是开发流程的核心矛盾变了:从“怎么写”变成了“怎么确认这段代码真的符合接口、类型和业务约束”。
类型安全在这个环节的价值,不是帮你写代码,而是帮你快速失败。一个类型错误如果能在编译期或 CI 阶段被发现,成本是几秒钟;如果等它运行到生产环境,成本可能是几小时甚至几天的事故排查。LLM 生成代码的量越大,这个“低成本失败机制”就越重要。
这也是我为什么建议所有使用 LLM 辅助开发的团队,先把类型检查的管线搭好,再去追求生成效率。模型写得快,你的检查必须比它更快。
1.2 类型系统能拦截哪些问题,拦截不了哪些
先说能拦截的:
- 函数参数数量、位置、类型不匹配。
- 访问了不存在的属性或方法。
- 返回值类型与声明不符。
- 该处理空值的地方没有处理,走了未定义分支。
- 数据结构字段缺失或类型不一致。
这些都是 LLM 生成的代码里最常见的低级错误。模型经常会把某个 API 的参数顺序记反,或者把一个可选字段当必选字段处理。这些错误传统代码评审看得慢,人工不容易一眼扫出来,但类型检查器几毫秒就能标记。
再说拦截不了的:
- 业务逻辑算反了,比如该加乘的地方用了减法。
- 类型完全正确,但数值范围不对,比如温度传感器返回了 -999。
- 存在安全漏洞,比如把未过滤的用户输入拼进了 SQL。
- 算法复杂度选错了,功能正确但性能不可接受。
- 并发和事务边界错误。
一句话:类型安全保证的是“形状正确”,不保证“语义正确”。这一点必须先想清楚,否则你会对类型系统抱有不切实际的期望,然后在某个逻辑错误面前觉得“类型安全也没用”。
这个判断是整篇文章的基础。后面所有流程设计,都建立在这条边界之上。
2. LLM 应用开发里的类型安全问题,比传统开发更突出
2.1 LLM 输出天然是字符串,结构化数据要自己做类型校验
如果说上一部分讨论的是“用 LLM 写代码”,这一部分讨论的是“开发 LLM 应用”。两者都要处理类型安全,但痛点不一样。
传统开发里,数据来自数据库或接口,一般已经有明确 schema,你只需要信任它并做少量校验。LLM 应用里,模型的输出是自然语言生成的字符串,哪怕你要求它“返回 JSON”,它也可能返回一段带解释文字的 JSON,或者字段名拼错的 JSON,或者缺少必填字段的 JSON。
我自己踩过最典型的坑是:让模型返回一个包含日期和金额的对象,结果模型把日期格式写成了“2024年3月5日”,金额写成了“一百二十三块”。如果代码里只是简单 json.loads,根本不会报错,但后续计算全部出错,而且报错位置离数据源头很远,排查要花很长时间。
所以从第一天开始,就应该把 LLM 的回应当作“来自不受信任来源的输入”来处理。不是相信它格式正确,而是假设它大概率格式不正确,然后通过类型校验把它拦在业务逻辑之前。
2.2 结构化输出:从 json.loads 到 Pydantic / Zod
最简单的做法是直接解析 JSON:
import json import llm_client raw = llm_client.chat("提取订单信息,返回 JSON") data = json.loads(raw) print(data["order_id"])这段代码在模型输出不规矩时,要么抛异常,要么返回一个缺字段的 dict。后续代码一访问 data["order_id"] 就直接 KeyError,而且是在业务代码深处才报出来。
更稳妥的方式是在解析后立刻做一次结构校验。Python 里最常见的是 Pydantic:
from pydantic import BaseModel, Field from datetime import date from decimal import Decimal class OrderInfo(BaseModel): order_id: str amount: Decimal = Field(gt=0) created_at: date # 假设 raw_json 是模型返回的 JSON order = OrderInfo.model_validate_json(raw_json)这段代码做的事情比 json.loads 多得多:
- 检查 order_id 是否存在且是字符串。
- 检查 amount 是否能转成 Decimal,并且大于 0。
- 检查 created_at 是否是一个合法的日期。
- 任何一项不满足,立刻在数据入口抛错。
这就是把“类型检查”前置。TypeScript 生态里对应的是 Zod:
import { z } from "zod"; const OrderInfoSchema = z.object({ orderId: z.string(), amount: z.number().positive(), createdAt: z.coerce.date(), }); const order = OrderInfoSchema.parse(rawJson);这类库不复杂,但价值非常大。它把模型输出的校验从“随缘”变成“强制”,而且报错信息直接指向具体字段,不需要在产品日志里大海捞针。
注意:不要只校验一层就完事。如果 LLM 输出会进入数据库或下游接口,入口校验、存储前校验、接口返回前校验这三处都需要覆盖。模型输出的不确定性不会因为你校验过一次就消失。
3. 用“类型安全”给 LLM 生成的代码当护栏:从提示词到静态检查
3.1 提示词里把类型、接口、边界条件写清楚
很多人让 LLM 写代码,描述只有一句话:“写一个函数计算折扣”。结果模型自由发挥,函数名、参数、返回类型都跟项目现有风格不一致。合入代码时,开发者要手工改签名、改调用点,改完又引入新错误。
更稳的做法是把接口契约写进提示词。比如:
请用 Python 实现一个函数: 函数签名: def calculate_discount(price: Decimal, member_level: str, coupon_code: str | None) -> DiscountResult 类型说明: - price 必须大于 0,否则抛出 ValueError。 - member_level 只能是 "normal"、"silver"、"gold",其他值按 "normal" 处理。 - coupon_code 为 None 时不做优惠券抵扣。 - DiscountResult 是一个 dataclass,包含 final_price: Decimal 和 reason: str。 要求: - 给出完整实现,包含类型标注。 - 不要修改签名。 - 边界条件要处理。为什么要把这些细节写进提示词?因为 LLM 是上下文学习模型,你给它的约束越具体,它越容易产出符合约束的代码。你只写“写个折扣函数”,它会在“折扣比例放函数内部还是外部”“是否支持多张券叠加”“返回 dict 还是对象”这些问题上自由发挥。自由发挥不是错,但在真实项目里,不确定的接口设计比不完善的实现更致命。
3.2 用类型检查和静态分析收口
提示词约束只是提高概率,不能保证结果。所以下一步是建立自动化的“收口”流程:把 LLM 生成的代码放进和人类代码一样的检查管道里,一视同仁。
以 Python 团队为例,我建议至少配置三层:
- 类型检查:mypy 或 pyright,开 strict 模式。
- 静态分析:ruff,处理未使用导入、未定义变量、常见反模式。
- 单元测试:对 LLM 生成的纯函数,补至少三组用例:正常值、边界值、异常值。
命令行流程大致是这样:
# 第一次生成代码后,先过静态检查和类型检查 ruff check generated_module.py mypy --strict generated_module.py # 再跑测试 pytest tests/test_generated_module.py -q如果这三步都通过,才进入人工代码评审。任何一步失败,把报错信息回传给 LLM,让它自行修复,最多重试两到三次。如果还修不好,说明这段代码的业务逻辑或接口定义本身有问题,硬改下去没有意义。
这里有一个经验:不要把模型修复的报错原封不动地塞回去。要把报错信息、相关源码上下文、你的期望三者一起给它。只给“这里有 bug,请修复”,模型很可能修了一个表面问题,又引入新问题。
3.3 运行路径上的动态校验
静态类型检查覆盖的是“代码本身的形状”,对于“数据运行时的形状”,还要靠运行时校验兜底。尤其是网络请求、配置文件、数据库读取这些边界位置,LLM 生成的代码特别容易忽略校验。
比如,模型生成了一个从配置读取超时时间的代码:
timeout = config["request_timeout"]如果配置缺失或者值是字符串,这段代码在静态检查下完全合法,运行时会直接抛 KeyError 或类型比较错误。更稳妥的写法是:
from pydantic import BaseModel, Field class AppConfig(BaseModel): request_timeout: float = Field(default=5.0, gt=0) max_retries: int = Field(default=3, ge=0) config = AppConfig.model_validate(config_dict)把外部输入在入口做类型转换和校验,后续业务代码就不用到处防御。这个思路对 LLM 生成的代码尤其重要,因为模型很少主动做防御性编码,你不给它上套,它就不会自己套。
4. 不同语言的类型安全策略对比:没有银弹,但各有侧重
4.1 Python:LLM 的主场,但需要主动加约束
Python 是目前 LLM 生成代码最多的语言,生态最成熟,但也最容易写出“运行时才炸”的代码。好消息是,Python 3.10 以后类型标注能力越来越强,配合 mypy/pyright 可以把动态语言变成半静态检查。
我在 Python 项目里推荐的做法是三件套:
- 所有新写的函数必须有类型标注。
- mypy 开 strict 模式,CI 里作为必须通过的一步。
- 数据入口统一用 Pydantic 定义模型。
这三件事做完,LLM 生成的 Python 代码的存活率会明显提高。但要注意,类型标注只能约束代码作者自己声明的行为,如果 LLM 生成了大量 Any 类型的对象,严格模式会放过它们,等于没检查。所以还要额外检查代码里 Any 的数量,Any 越多,说明这段代码的类型信息越不可信。
4.2 TypeScript / Java / Rust:编译器的天然护栏
TypeScript 项目里,tsc 的 noImplicitAny、strictNullChecks 这些选项天然就是为 LLM 生成的代码准备的。一个对象如果字段没定义,访问时立刻编译报错。这让 LLM 生成的组件代码在合并之前就能被拦下一大批低级问题。
Java 这类强类型静态语言也一样,编译器本身就是最好的代码评审员。LLM 生成的代码如果方法签名对不上、类型不匹配,javac 直接拒绝编译。代价是写起来更啰嗦,但对生产系统来说这是收益不是成本。
Rust 更特殊。它不仅有类型检查,还有所有权和借用检查。LLM 生成 Rust 代码经常在所有权、生命周期、借用冲突上翻车,而且这些错误编译器会给出非常详细的提示,反而让“用 LLM 迭代修复”变得可行。我见过不少团队用 Rust 加 LLM 辅助开发,流程很痛苦但结果质量相当稳定,因为编译器把住了最后一道关。
4.3 一张表看清不同语言怎么跟 LLM 配合
| 语言 | 类型检查机制 | LLM 生成代码的常见问题 | 推荐护栏 | 适合场景 |
|---|---|---|---|---|
| Python | 运行时 + mypy/pyright | 缺类型标注、参数顺序错、None 未处理 | strict 模式 + Pydantic + 单测 | 数据处理、AI 应用、原型快速验证 |
| TypeScript | tsc 编译期 | 任意类型、null 未收窄、接口字段缺失 | strict + Zod + ESLint | 前端、Node 服务、全栈 |
| Java | 编译期强类型 | 异常未捕获、泛型误用、接口设计过重 | 编译必过 + 代码评审 + 测试 | 企业级后端、大型系统 |
| Go | 编译期 | 错误处理欠缺、interface 滥用 | go vet + error 规范 + 测试 | 云原生、中间件、网络服务 |
| Rust | 编译期 + 所有权 | 生命周期冲突、所有权转移、unwrap 滥用 | cargo clippy + 禁止 unwrap 规范 | 系统软件、性能敏感服务 |
这张表不是让大家立刻换语言,而是提供一个判断依据:你所在的语言,类型检查能帮你挡住哪类问题,挡不住哪类问题,然后据此设计 LLM 代码的验收流程。
5. 实际落地经验:怎么把 LLM 代码的类型安全流程建起来
5.1 一个可复用的工作流
我把这套流程在团队里跑过一段时间,整理成七个步骤,按顺序执行:
- 先写接口契约,再让模型写实现。契约包括函数签名、入参类型、返回类型、异常规则。
- 把契约写进提示词,附带一到两个项目内的相似代码示例做风格参考。
- 生成代码后,先用静态分析和类型检查做第一轮过滤。
- 报错不足三次的,把报错反馈给模型自动修复;超过三次还不过,人工介入。
- 人工评审只关注类型检查覆盖不了的部分:业务逻辑、边界条件、异常处理、安全因素。
- 补充至少一个正常用例、一个边界用例、一个异常用例,并确认测试真的会失败。
- 通过后合入,同时记录这段代码的来源标记,便于后续追踪。
第 6 步容易被忽略。很多测试是模型自己生成的,而模型生成的测试经常是“恭喜型测试”,输入输出都对,但删掉断言它照样通过。所以要人工确认每个测试在特定输入下真的能扣住行为。
5.2 代码评审时的检查清单
把下面这张清单贴在 PR 描述模板里,每次评审 LLM 生成的代码时按项勾选:
- 是否所有函数都有类型标注或类型声明。
- 是否存在未使用的导入和变量。
- 外部输入(配置文件、请求参数、LLM 输出)是否在入口完成校验。
- 返回结构是否稳定,字段名是否有拼写风险。
- 异常分支是否处理,还是所有错误都靠顶层 try 兜住。
- 有没有使用非确定性的随机值或不可控的超时时间。
- 日志里是否能定位到具体字段和具体行号。
这些条目看起来基础,但 LLM 生成的代码恰恰在这些基础点上最不稳定。我见过不少看起来非常完整的函数,实际上一半的导入都没用过,错误处理全部空洞地抛给调用方,日志信息不含任何上下文。这些不是类型错误,但类型安全意识会提醒你多问一句“这段代码出了问题,我能快速定位吗”。
5.3 最容易踩的四个坑
第一个坑:把模型当作绝对正确的代码来源。模型生成的代码哪怕通过了全部类型检查,仍然是“看起来合理”的代码,不代表它真的满足需求。类型检查和测试只能证明它没有明显错误,不能证明它做了正确的事。
第二个坑:忽略依赖版本。模型经常生成最新版本的 API 调用,而项目里装的可能是旧版本。这种情况静态检查不一定报错,因为方法名存在,但行为已经变了。我建议生成代码时在提示词里写明依赖版本,或者至少要求模型标注使用的库版本。
第三个坑:路径和权限问题被当作代码问题。这个问题在本地和 CI 环境特别常见。模型生成的代码里写死了相对路径,或者访问了 CI 容器里不存在的目录,报错信息看起来像代码错误,实际上是运行环境问题。遇到这类报错,先看运行环境和输出目录,再改代码。
第四个坑:让模型写测试但不审测试。前面已经说过,模型生成的测试经常是自我验证型。一定要人为构造一个破坏性输入,确认测试真的能报错。如果测试在你故意改错代码后仍然通过,那这个测试没有意义。
6. 类型安全解决不了的问题,以及接下来值得关注的方向
6.1 边界要清楚:类型安全不等于代码正确
整个类型安全这套体系,本质上是把“人类比较容易犯的低级错误”自动化地拦截掉。它不负责判断业务目标是否达成,也不负责发现需求理解偏差。
举例来说,一个计算订单总额的函数,类型标注和运行时校验都完全正确,但折扣规则本身就和业务方确认的不一样——类型系统不会发现,测试也不会发现,只有懂得业务的人才能发现。这也是为什么我坚持流程里必须有“人工评审”这一环,而且评审重点不是语法,而是语义。
把类型安全当作第一道防线,把人工评审当作最后一道防线,中间用测试和静态分析填充。这个顺序不要颠倒。
6.2 LLM 进入更多结构化领域,类型安全要求会更高
最近能看到一个明显的趋势:LLM 不再只处理文本和代码,开始被用进时间序列预测、数据库查询、数据管道配置这类强结构化场景。比如 time-llama 这类尝试,用动态低秩适配的方式把 LLM 迁移到时间序列预测任务上。这类方向的共同点是:输入和输出都有严格的维度、范围、单位约束,比普通文本生成更容易出错。
在这种场景里,“类型安全”的概念会被扩展:不仅字段类型要匹配,数值范围要合法,时间戳不能乱,序列长度要一致,缺失值要显式表示。任何一层没做校验,模型输出的结果就算看起来“很有道理”,也无法直接进下游计算。
所以我的判断是,结构化输出校验会从 LLM 应用开发里的一个加分项,逐步变成必选项。现在开始把 Pydantic、Zod 这类工具用熟,不是浪费时间。
6.3 给不同阶段团队的建议
如果你还在个人项目或原型阶段,要求可以放低,但至少做到两件事:LLM 生成的代码过一遍类型检查;LLM 返回的数据用结构校验解析。这两步加起来成本不到半小时,但能过滤掉大部分低级错误。
如果你在维护一个多人协作的生产项目,我建议把类型检查、静态分析、结构化输出校验全部变成 CI 强制步骤,并且给 LLM 生成的代码单独打标签,保证代码来源可追踪。评审时不要因为“是 AI 写的”就降低标准,反而要更严。因为模型不会为自己的错误负责,只有你的流程会。
如果你在负责架构选型,可以重新评估一下当前技术栈的类型表达能力。不一定要切到 Rust,但至少要保证 LLM 生成的代码进入项目时,有一条能自动拦截类型错误的检查链路。没有这条链路的语言和项目,在 LLM 时代的维护成本会越来越高。
回到最开始的问题:在 LLM 时代,编程语言和类型安全到底能帮我们做什么?我的答案很直接:它是第一道安检门,也是唯一一道能全自动执行的安检门。它拦不住业务逻辑错误,拦不住需求理解偏差,但它能把模型最擅长犯的低级错误挡在门外。先把这道门修好,再谈让 AI 替你写代码,顺序不能反。踩过几次坑之后你会发现,真正让 LLM 代码变得可用的,不是模型本身变强了多少,而是你周围那圈检查链路变强了多少。