Linkly 这个项目的切入角度很有代表性:它不是在 LLM 应用外层再包一个 Python SDK,而是声明一种“面向 LLM 设计的语言”,并且选择 MLIR 作为编译后端。把这句话拆开看,前半句是在回应 LLM 应用工程化的问题,后半句是在回应编译器基础设施的问题。简单说,Linkly 想做的事情是:把“模型应该完成什么任务、调用哪些工具、返回什么格式、失败时怎么处理”这类原本散落在提示词和胶水代码里的约定,收拢成一种可以被解析、校验、优化和编译的语言。
实际项目里,这个问题的痛感非常明显。让大模型完成一个带工具调用的业务任务时,通常要写提示词模板、定义 function calling 的 JSON Schema、解析模型输出、做格式校验、处理重试。这些逻辑可能分布在好几个文件里,彼此没有类型约束,改了输出格式经常会忘记同步提示词。Linkly 代表的方案是反过来的:先定义一门语言,把任务契约写成源码,再由编译器负责把源码变成提示词、工具调用协议、输出校验器和执行计划。
这篇文章会按这个顺序展开:先理解 LLM 应用为什么需要专门的语言,再理解 MLIR 在这条路线里承担什么角色,然后搭建一个最小实验来复现类似流程,最后讨论语言设计、常见坑、排查链路和生产落地方案。我不会假设已经掌握了 Linkly 仓库里的全部源码细节,只从公开标题确认它“面向 LLM 设计语言,并经过 MLIR 编译”,具体方言和 Pass 设计要以仓库源码和文档为准,但思维方式可以完整复用。
1. 当 LLM 应用从“提示词拼接”走向“任务编译”时,Linkly 在解决什么问题
1.1 LLM 应用开发现状:提示词、函数调用和 JSON 为什么不够
目前大多数 LLM 应用的核心开发方式,可以概括为三层结构:第一层是提示词模板,把用户输入和业务上下文拼成字符串;第二层是模型调用层,负责请求 API 并拿到输出;第三层是解析层,把模型输出转成结构化数据,通常就是 JSON。看起来简单,维护起来却是另一回事。
举一个常见例子,假设要做一个文档总结功能:
def summarize_docs(docs): prompt = f""" 请阅读以下文档并返回 JSON: {docs} 返回格式: {{ "summary": "string", "topics": ["string", "string"] }} """ response = llm_call(prompt, temperature=0.2) return parse_json(response)这段代码能跑,但它把“任务语义”完全压进了字符串里。输出格式描述、字段含义、解析逻辑和提示词内容是分散的。如果产品经理要求把topics字段改成categories,你需要同时改三处:提示词里的 JSON 示例、parse_json的解析逻辑、下游使用方。漏掉任何一处,问题都只能等运行时才暴露。
函数调用(function calling)方案缓解了一部分问题,它把工具签名从自然语言中抽出来。但函数调用协议仍然是运行时结构,不是源码级别的一等公民。你没法在写代码的时候静态检查某个工具是否存在、参数类型是否匹配、返回结构是否完整。业务逻辑越复杂,这种“约定靠人记”的脆弱感就越强。
还有一个更尖锐的问题:这些约束没有一个统一的编译期检查点。提示词里的格式要求写得再细,模型也可能不遵守。普通程序里编译器会在代码运行前发现类型错误,LLM 应用的当前实践基本做不到。
1.2 Linkly 的定位:让任务定义进入编译流程
Linkly 提出的思路,是把“任务”做成语言里的一等公民。开发者不是写一段提示词,而是写一段带结构的语言源码。源码里可以声明任务名、输入变量、输出结构、工具列表、失败策略,甚至返回格式。
如果用“Linkly 风格”的思路来组织刚才的文档总结任务,声明形态可能类似这样:
task "summarize_docs" { input { docs: text } output { summary: string topics: list(string) } tools: [] prompt: "Summarize the provided docs and return the structured result." }这段代码不是 Linkly 官方语法,而是用于说明这类语言的设计思路。它和字符串提示词最大的不同在于:输入、输出、工具、提示词是分开的字段,编译器和编辑器可以对每个字段单独做检查。
编译过程可以做几件原来的胶水代码做不到的事。首先,可以静态检查输出结构是否被正确引用;其次,可以根据输出声明自动生成校验器;再次,可以把任务源码转换成模型实际看到的提示词和工具协议;最后,还可以针对不同模型生成不同的执行计划。这个“源码 -> IR -> 目标代码”的流程,正是 LLM DSL 和普通脚本之间的本质区别。
1.3 这门语言和“给程序员的 DSL”有什么不同
传统 DSL 的目标是降低人类表达成本,消费者是人或传统编译器。SQL 让你不需要写遍历算法就能查询数据,CSS 让你不需要描述渲染过程就能表达样式。它们的共同点是:语法确定,语义确定,执行结果确定。
面向 LLM 的 DSL 不太一样。它的消费者有两位:第一位是人类开发者,需要靠语言表达意图;第二位是模型,它要在运行时读取由语言生成的提示词和工具说明,并产出结果。也就是说,模型不是语言的执行器,而是语言编译产物要对接的“运行时后端”。
这会带来几个传统 DSL 没有的约束。一是语义不能过度自由,否则模型无法稳定执行;二是语言层要保留足够的运行时信息,因为模型调用需要温度、模型名、超时、工具描述等参数;三是输出必须可以被校验,因为模型不是确定性的执行单元。
这也解释了为什么 Linkly 会强调“通过 MLIR 编译”。普通脚本直接把代码解释执行就够了,但面向 LLM 的语言一旦需要做校验、降级、多后端转换,就会非常需要一个成熟的编译器基础设施。
| 维度 | 普通 DSL | 面向 LLM 的 DSL |
|---|---|---|
| 主要消费者 | 人类和运行时 | 人类、编译器、模型 |
| 执行确定性 | 高 | 低,模型输出有概率性 |
| 核心产物 | 指令、查询、样式 | 提示词、工具协议、执行计划 |
| 编译期价值 | 语法检查、优化 | 静态校验、Schema 生成、多模型适配 |
| 运行时目标 | CPU、数据库、浏览器 | 模型推理服务、工具 API |
2. 为什么这条路要选择 MLIR,而不是再写一个 AST 解释器
2.1 用一句话理解 MLIR
MLIR 全称是 Multi-Level Intermediate Representation,是 LLVM 项目里的一套多级中间表示框架。它不是一种单一的 IR,而是一套“方言(Dialect)+ 流水线(Pipeline)+ 转换(Conversion)”的生态系统。每个方言定义一组操作和类型,不同方言可以表达不同抽象层级,Pass 负责分析、优化和把高层方言转换成低层方言。
传统编译器工作流通常是“源码 -> AST -> 中间代码 -> 机器码”。MLIR 把这个流程细化了,允许在中间阶段使用多种 IR。比如机器学习编译器里,可以用tensor方言表达张量操作,转成linalg方言做循环优化,再降到llvm方言生成机器码。
对 Linkly 这类语言来说,MLIR 的价值在于:它可以为“任务语义”定义高层方言,为“模型调用和工具协议”定义低层方言,然后通过标准化的 Pass 机制完成转换。这样语言设计者不需要从零写一整套编译器基础设施。
2.2 从 AST 直接解释执行有什么问题
如果一个 LLM DSL 只是想做语法糖,手写 AST 和解释器也够用。定义一个TaskNode,里面放任务名、输入输出字段、提示词字符串,然后遍历 AST 生成 JSON 或调用 Python 函数。听起来简单,但一旦项目深入,问题就会接连出现。
第一个问题是缺少标准化的验证工具。手写 AST 的合法性依赖开发者自己的Visit逻辑,字段漏判、类型写错、循环解析问题都要自己处理。MLIR 自带mlir-verify机制,操作定义好后,IR 合法性能被自动检查。
第二个问题是优化很难做。当你想对任务做分析,比如“哪些工具调用可以并行”“哪些字段没有被使用”,手写遍历逻辑会越来越复杂。MLIR 的角色就是把人从这些重复劳动里解放出来,Pass 机制天然支持增量添加分析和转换。
第三个问题是多后端支持困难。LLM DSL 的编译产物不一定是传统机器码,可能是模型调用计划、工具脚本、甚至另一个 DSL。手写代码生成器容易写成面条式逻辑。MLIR 的方言转换机制可以把“高层语义”和“低层代码生成”分离,每一层都可以独立测试。
2.3 方言、Pass、转换在 Linkly 管线里各负责什么
在 MLIR 语境下,方言是词汇表。Linkly 如果要落地,很可能会定义一个linkly方言,里面包含任务声明、输入输出声明、工具调用、约束条件等操作。这组操作描述了 LLM 任务的语言语义,但不关心最终如何被模型执行。
Pass 是操作流水线。例如一个叫linkly-lower-to-runtime的 Pass,负责把linkly.task操作转换为更底层的“运行时调用”方言。另一个叫linkly-verify-output的 Pass,可以在编译期检查输出 Schema 是否完整。
转换是层与层之间的桥。高层方言描述“做什么”,低层方言描述“怎么做”。MLIR 的 dialect conversion 机制让这种逐级下降成为一项工程纪律,而不是临时写一个函数转一下。
对 Linkly 而言,一个典型的编译管线可能是:
linkly.task 定义 ↓ linkly-lower-to-tools 工具调用方言 / 模型调用方言 ↓ linkly-lower-to-runtime 可执行任务计划(JSON / IR / 运行时指令)这个流程的好处是每一层都可以单独打印、验证和测试。IR 在哪一步被破坏,用一条命令就能看到。
2.4 为什么不是直接生成 LLVM IR
有人会问,MLIR 最终还是要降到 LLVM IR,为什么不直接生成 LLVM IR?这里的关键是抽象层级不匹配。
linkly.task这种操作里包含的是任务名、提示词模板、JSON 输出结构、工具签名。这些信息在 LLVM IR 里根本没有对应的表达方式。如果把高层语义直接拼到 LLVM IR,还需要额外的 metadata 来携带任务信息,用起来非常别扭。
MLIR 允许在高层保留这些语义,逐层下降。编译器可以先做任务层面的分析,比如检查某个工具是否被引用、输出字段是否存在、提示词是否和输出 Schema 一致。这些分析在高方言层做最自然。等所有任务语义都被验证完了,再转换成更具体的调用指令。
多后端能力也值得考虑。Linkly 的最终产物不一定非要变成 LLVM IR,也可能是某一个 LLM 推理服务的 JSON 请求,或一个任务编排器能读取的执行计划。MLIR 的方言机制让“同一个高层语言,生成不同执行平台”成为可能。
3. 最小复现实验:搭建一个 Linkly 风格的 LLM 方言编译骨架
这一部分的目的不是复刻 Linkly 官方实现,而是搭建一个能验证“LLM 任务语言 + MLIR 编译”思路的最小骨架。你本地不需要真实跑通大模型,只需要看到方言被定义、IR 被解析、Pass 能执行这个编译闭环。
3.1 环境准备:先确认 LLVM/MLIR 版本
开发 MLIR 方言的前提是有一个可用的 MLIR 工具链。最稳妥的方式是直接从 llvm-project 源码构建:
git clone https://github.com/llvm/llvm-project.git cd llvm-project mkdir build && cd build cmake -G Ninja ../llvm \ -DLLVM_ENABLE_PROJECTS="mlir" \ -DLLVM_BUILD_EXAMPLES=ON \ -DCMAKE_BUILD_TYPE=Release ninja check-mlir源码构建耗时较长,但能保证你拿到的是包含完整 MLIR 开发库的环境。如果不打算开发自定义方言,只学习现有 IR 格式,也可以安装发行版提供的 mlir 包,但要仔细确认版本。MLIR 的 API 变动比较频繁,不同版本之间 TableGen 语法和 Pass 注册方式可能有差异,实验前先固定一个版本,避免出现“照着文章跑不通”的情况。
构建完成后检查工具:
./build/bin/mlir-opt --version ./build/bin/mlir-translate --version这里额外提醒一点:MLIR 是一个快速演进的项目,如果你使用的版本较新,某些 CMake 变量名和头文件路径可能变化。遇到报错时优先查看官方mlir/examples/standalone示例,这是官方维护的独立方言开发模板,比任何二手教程都更适合作为基准。
3.2 项目目录与 CMake 骨架
自定义 MLIR 方言的目录结构可以参照官方 standalone 示例,通常包含 include、lib、tools 三个目录:
linkly/ ├── CMakeLists.txt ├── include/linkly/Dialect/Linkly/ │ ├── LinklyDialect.td │ ├── LinklyOps.td │ └── LinklyOps.h ├── lib/Dialect/Linkly/ │ ├── LinklyDialect.cpp │ ├── LinklyOps.cpp │ └── CMakeLists.txt └── tools/ ├── linkly-opt/ │ ├── linkly-opt.cpp │ └── CMakeLists.txt └── linkly-translate/这个结构对应 MLIR 方言开发的基本约束:TableGen 文件定义方言和操作,C++ 文件实现注册和接口,命令行工具负责把 IR 跑起来。linkly-opt的作用类似mlir-opt,只不过它额外注册了 Linkly 方言,可以接受.mlir格式输入并打印转换后的 IR。
3.3 用 TableGen 定义最小方言和操作
MLIR 使用 TableGen 定义方言和操作。下面是一个最小方言定义片段:
// include/linkly/Dialect/Linkly/LinklyDialect.td def Linkly_Dialect : Dialect { let name = "linkly"; let summary = "A dialect for LLM-oriented task language"; let cppNamespace = "::mlir::linkly"; }对应操作定义:
// include/linkly/Dialect/Linkly/LinklyOps.td include "mlir/IR/OpBase.td" include "LinklyDialect.td" def Linkly_TaskOp : Op<Linkly_Dialect, "task"> { let summary = "Declares an LLM task"; let arguments = (ins StrAttr:$name ); let results = (outs); let assemblyFormat = "$name attr-dict"; }这段代码定义了一个名叫linkly.task的操作,它接受一个字符串属性作为任务名。assemblyFormat决定 IR 的文本打印和解析格式,这里设置为“操作名 + 任务名字符串 + 属性字典”。这不是 Linkly 官方的操作定义,只是一个用于理解流程的最小示例。
3.4 在 linkly-opt 里验证方言解析
编译成功后,可以创建一个测试文件:
module { linkly.task "summarize_docs" }然后运行:
./build/bin/linkly-opt task.mlir正常情况下,工具会把这个 IR 原样打印出来:
module { linkly.task "summarize_docs" }这一步验证的是最基础的能力:方言注册成功、操作能被解析、IR 能通过验证。如果这里就报错,说明方言注册或 TableGen 生成的代码有问题,需要先解决前端问题,再进入下一步。
3.5 加一个简单 Pass 观察编译流程
为了让“编译”概念更明显,可以在工具里注册一个最简单的 Pass,比如把linkly.task转成另一个标记操作:
struct LowerTaskPass : public mlir::PassWrapper<LowerTaskPass, mlir::OperationPass<>> { void runOnOperation() override { getOperation()->walk([](mlir::linkly::TaskOp op) { op.emitRemark() << "found task: " << op.getName(); }); } };这个 Pass 只做了遍历打印,不代表真正的 Lowering,但它能让你理解 Pass 在管线里的作用:遍历 IR、分析操作、做转换。接下来只要继续注册更多转换 Pass,把linkly.task降级到表示模型调用和工具协议的方言,就是一个链路完整的 LLM 任务编译器骨架。
跑通这一步后,你的本地环境就具备了继续深入的条件。后面的重点不再是 MLIR API 语法,而是语言本身怎么设计。
4. 面向 LLM 的语言设计:除了语法,还要定义哪些语义
MLIR 只是容器,语言设计才是 Linkly 这类项目真正要解决的问题。面向 LLM 的语言不能只定义语法,必须把任务语义、工具语义、输出语义和运行时语义全部讲清楚。
4.1 任务声明:把提示词升级成有结构的语言单位
第一件事是把提示词从“字符串”升级成“对象”。一个任务声明的核心字段至少应该包含:任务名、输入参数、输出结构、提示词、工具清单、错误处理策略。把所有这些信息放在源代码里,编译器才能在编译期做一致性检查。
例如,一个带输入输出声明和工具列表的任务,可以这样组织:
task "retrieve_and_summarize" { input { query: string top_k: int } output { summary: string sources: list(string) } tools: ["search", "read_page"] prompt: "Search for relevant pages, read them, and summarize." retry: 2 }这个声明里最关键的语义不是提示词,而是input和output。有了输入输出声明,编译器就能生成两样东西:一是用于引导模型的提示词片段,二是用于校验模型输出的验证器。如果模型返回的 JSON 缺少sources字段,验证器可以直接报错,不需要程序员另外写一堆if "sources" not in data的判断。
4.2 工具签名:让外部功能进入类型系统
LLM 应用中大量任务是语言模型和外部工具配合完成的,检索、数据库查询、计算、HTTP 请求都需要工具调用。目前这些工具通常通过 function calling 协议暴露给模型,但协议描述的是运行时结构,没有参与编译期检查。
在面向 LLM 的语言里,工具签名应当成为类型系统的一部分:
tool "search" { args { query: string top_k: int } returns { results: list(string) } }定义好工具签名后,任务声明里引用工具时就可以做静态检查。task里声明用了search工具,但传入了top_k: string,编译器直接报错。工具不存在、参数不匹配这类问题,不用等到模型调用 API 才发现。
当前不少 Agent 框架引入了 MCP(Model Context Protocol)这类协议来统一工具接入。语言层定义工具签名,运行时再映射到 MCP 工具或特定 API 协议,是比较自然的衔接方式。编译器的职责是确认“语言层签名”和“运行时工具定义”保持一致。
4.3 输出契约:格式、约束和失败处理
模型输出的最大特点是不稳定。即使提示词里写了“必须返回 JSON”,真实输出里也可能出现多余解释、字段缺失、类型错误。普通程序通常不面对这个问题,但 LLM DSL 必须把“输出可能不合法”当作正常情况来处理。
语言层需要定义输出契约和失败策略:
output { format: "json" schema: { summary: string topics: array[string] } fallback: "empty_result" validation: "strict" }这里的fallback字段告诉运行时:当模型输出连续验证失败时,返回什么兜底结果;validation字段告诉校验器,缺失字段是直接失败还是允许部分返回。这些信息进入语言源码后,编译器可以自动生成校验代码、重试循环和错误日志结构。
生产环境里,输出校验失败不能只靠“重新调用一次模型”。语言层应该把“重试次数、超时时间、兜底策略”这些语义显式表达出来,编译器再把它们转换成运行时配置。
4.4 运行时信息:编译结果到底在编译什么
传统的编译结果是机器码或字节码。LLM DSL 的编译结果则更像一个“可执行的计划”。它可能包含模型调用参数、提示词模板、工具请求、输出校验器、重试策略。这个计划最终要交给一个运行时系统执行,运行时系统负责调用模型、调度工具、校验输出。
所以语言设计时必须保留运行时信息,包括模型名、模型温度、最大 token 数、工具调用超时等。这些信息可以像目标平台配置一样出现在编译选项里:
compile_options { model: "gpt-4o-mini" temperature: 0.2 max_tokens: 1024 timeout_seconds: 30 }把模型调用参数放在语言层而不是应用层,意义在于:同一段任务源码可以针对不同模型重新编译。有的模型支持严格 JSON 模式,有的模型不支持,编译器可以根据模型能力生成不同的提示词和校验策略。这与传统编译器中“针对不同 CPU 架构生成不同机器码”的思路非常相似,也是 Linkly 选择 MLIR 后最有想象力的部分。
5. 最容易踩的三个坑:输出解析、方言层级和工具调用
把“语言设计 + MLIR 编译”的思路落到实际项目时,有三个坑出现频率很高。提前理解它们,能省下大量排查时间。
5.1 坑一:模型输出解析与语言约束没有形成闭环
很多人在语言层定义了输出结构,但模型返回后仍然用一个孤立的parse_json函数处理。输出 Schema 和解析逻辑分别维护,Schema 改了,解析函数没改,编译期又检查不出来。
出现这种现象的根本原因是:语言约束只存在于源码里,没有通过编译器生成实际可用的校验和解析代码。
推荐做法是让编译流程同时生成三类产物:提示词片段、输出校验器、解析代码。语言源码改一次,三个产物同步更新。这样语言约束和运行时解析就不存在“两套真相”的问题。
5.2 坑二:方言和 Pass 没有分层,所有逻辑放在一个大方言里
新手设计 MLIR 方言时,容易把所有操作塞进同一个方言,天真地认为“反正都能跑”。问题是当你想做优化时,高层任务语义和低层模型调用协议混在一起,很难写通用 Pass。
一个可行的分层方式是:高层方言只描述任务和工具契约,不关心模型怎么调用;低层方言描述具体的模型请求、工具 HTTP 调用、重试逻辑;两者之间通过 dialect conversion 连接。
动手写代码之前,先花半天时间画一张方言分层图,明确每个操作属于哪一层,远比写完后重构省时间。
5.3 坑三:工具调用没有考虑并发、幂等和超时
语言定义了tool "search",但执行时可能产生两类问题:一是并发调度不当,多个任务同时调用同一个有状态工具;二是工具调用失败或超时后重试,造成重复副作用。
语言层应该把工具调用的运行时语义也纳入设计,包括超时时间、重试次数、幂等键、并发限制。这些问题看起来是运行时的事,但如果语言设计时完全不管,编译器就无法生成对应的容错逻辑。
5.4 踩坑总结表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 输出 Schema 改了,解析端大量报错 | Schema 和解析逻辑两套维护 | 搜索提示词中 JSON 示例与解析函数是否同时更新 | 由编译器生成解析和校验代码 |
| 想加优化 Pass,发现要改遍所有 Op | 方言没有高低层分离 | 查看 IR 中任务语义和实现细节是否混在一个 Op 里 | 设计多层方言,用 Conversion 连接 |
| 批量任务运行后出现重复副作用 | 工具调用缺少幂等和超时语义 | 中断任务后检查工具日志是否重复提交 | 在语言层定义幂等键、超时和重试策略 |
6. 问题排查链路:从 LLM DSL 源码到模型输出的五步定位法
面向 LLM 的编译器涉及面比普通编译器更广,因为问题可能出现在前端解析、方言转换、运行时调度、模型推理、工具接口任何一层。排查时如果没有顺序,很容易在错误层级浪费时间。
6.1 第一步:确认源码和前端解析
先确认问题是不是语法层面的。运行linkly-opt看看 IR 能否被正常解析。如果解析报错,说明任务源码里的语法、属性或参数引用有问题。检查重点包括:操作名是否正确、输入输出字段是否声明、工具名是否已定义。
排查时保持最小化。把源码裁剪到只剩一个task,逐步添加字段,确认哪一项让解析失败。
6.2 第二步:检查方言转换和 Pass
如果源码能解析,转换后的 IR 与预期不符,问题很可能在 Pass 或 dialect conversion。运行linkly-opt --print-ir-before-all --print-ir-after-all可以打印每个 Pass 执行前后的 IR,对比转换前后差异。
此时的关键问题是:转换后的 IR 是否减少了高层语义?是否混入了不要求的低层细节?如果一个高层 Op 在转换后消失了,但对应的低层 Op 没有出现,大概率是 Conversion 规则漏写了。
6.3 第三步:检查运行时和执行计划
IR 正确不代表模型输出正确。编译产物是“执行计划”,运行时拿到计划后,需要检查它是否包含完整的工具清单、输出校验器、重试策略和模型参数。这里最容易出的问题是:语言源码里的某些字段没有参与代码生成,运行时拿到的执行计划丢信息。
验证方式是把编译产物直接打印出来,逐字段和语言源码对照。如果源码里有retry: 2,但执行计划 JSON 里没有出现对应字段,说明编译生成逻辑漏掉了它。
6.4 第四步:检查模型调用层和工具接口
模型层的问题通常表现为:模型返回空结果、返回非 JSON、工具参数无法解析、API 限流。检查重点是提示词是否包含了由语言源码生成的输出结构说明,工具签名是否完整传递到 function calling 协议。
这类问题的特征是“编译产物正确,但运行时报错”,说明责任在模型接口或工具服务端,而不是编译器。先用最小提示词直接在模型 API 上测试,排除业务逻辑干扰。
6.5 第五步:用最小用例二分定位
如果问题跨越多个层级,可以做一个“最小复现用例”做二分定位:写一个只包含一个字段的 task,编译并执行;确认通过后,逐步加工具调用、加输出 Schema、加失败策略,每加一层就验证一次。找到导致异常的具体字段,再把问题缩小到对应 Pass 或运行时组件。
| 排查层 | 核心检查点 | 常用验证方式 |
|---|---|---|
| 源码解析层 | 语法、字段、类型 | linkly-opt 解析 |
| 方言转换层 | IR 的前后差异 | 打印 IR before/after |
| 执行计划层 | 字段完整性 | 对照源码检查编译产物 |
| 模型调用层 | 提示词、工具协议 | 直接请求模型 API |
| 工具接口层 | 参数、幂等、超时 | 查看工具服务端日志 |
7. 学习环境与生产环境:跑通实验之后,还差哪些能力
从可运行实验到生产系统,中间隔着一整条工程链路。下面把学习环境里“够用就行”的做法和生产环境里“必须补齐”的能力分开说明。
7.1 学习环境怎么快速验证
学习阶段的目标是理解编译链路,不建议一上来就追求完整方言设计。一个 mini 项目足够:两个方言、三个 Pass、一个 linkly-opt 工具、一个测试目录。验证标准可以是“能解析 IR、能转换 IR、能打印 IR”。
跑通一个最小实验后,可以尝试扩大边界:给linkly.task加输出结构字段,编写一个生成 JSON Schema 的 Pass,再写一个能把 task 翻译成模型提示词的 Pass。这就能覆盖“任务语言源码 -> 编译产物 -> 模型输入”的完整过程。
学习环境里不需要考虑权限、并发、日志告警。用mlir-opt和 Python 脚本做输入输出验证即可。
7.2 生产环境还需要什么
生产环境要处理的不是“能不能编译”,而是“编译产物跑起来后如何稳定、可观测、可回滚”。以下能力至少要补齐:
配置外置化。模型名、API 密钥、温度、超时等参数不能写死在语言源码或编译选项里,应该通过环境变量或配置中心注入。
日志和监控。编译器和运行时都要输出结构化日志。一个任务从源码到模型输出的完整链路要能追踪,模型调用耗时、重试次数、输出校验失败率都应该是指标。
权限和安全。任务源码本身可能有执行权限差异。哪些任务可以调用哪些工具,需要在编译期或运行时做控制。
回滚方案。语言版本和编译器版本要一起发布,否则旧任务源码可能被新编译器生成不同行为。编译产物要可缓存、可回滚。
数据备份和依赖隔离。如果编译产物会持久化,需要考虑存储和备份;如果多个业务共用同一个编译器,要考虑隔离。
7.3 环境差异对照表
| 能力 | 学习环境 | 生产环境 |
|---|---|---|
| 编译产物 | 打印 IR 即可 | 持久化、版本化、可恢复 |
| 配置管理 | 写死在命令行 | 外置配置、动态更新 |
| 日志 | 程序标准输出 | 结构化日志、链路追踪 |
| 模型参数 | 固定值 | 按任务、模型、环境差异化 |
| 工具调用 | 单机调试 | 超时、重试、幂等、并发控制 |
| 安全控制 | 不涉及 | 权限、鉴权、敏感信息保护 |
8. 可复用清单与后续扩展方向
8.1 语言设计检查清单
设计一门面向 LLM 的语言前,按这个清单过一遍,能避免很多后期返工:
- 是否把任务声明作为一等公民,而不是散落的字符串?
- 输入和输出是否有明确声明,编译器能否据此生成校验器?
- 工具签名是否参与类型检查,而不是只存在于运行时协议?
- 是否定义了模型输出不合法时的失败策略?
- 是否把重试、超时、幂等这类运行时语义纳入语言层?
- 是否保留模型名、温度、max tokens 等运行时参数?
- 是否有最小 IR 打印和验证流程?
8.2 编译器工程检查清单
- 方言是否按高层语义和低层实现分层?
- 每个 Pass 是否职责单一,并可以独立测试?
- 是否有 before/after IR 打印,方便定位转换问题?
- 是否有 round-trip 测试,IR 解析后重新打印保持一致?
- 是否固定了 LLVM/MLIR 版本,并在文档里记录?
- 编译产物是否可读、可校验、可回滚?
8.3 再往前一步:从 DSL 编译器到 Agent 编排框架
Linkly 这类尝试最有价值的地方,不是“发明一门语言”,而是把 LLM 任务从提示词工程推进到编译工程。顺着这个方向,有两条很自然的扩展路径。
第一条路径是把编译产物与 Agent 编排框架打通。任务源码编译后,生成的不只是提示词,还包括工具调用协议、RAG 检索步骤、MCP 客户端请求。DSL 成为 Agent 行为的高层描述语言,编译器负责把行为描述转换成可执行计划。
第二条路径是让语言的输出结构直接服务于模型能力评估。模型输出经过编译器生成的校验器校验后,失败样本可以结构化回流,用来分析模型在哪种任务约束下最容易出错。这等于让编译器承担了一部分“测试平台”的职责。
回到开头的问题:LLM 应用真的需要一门专用语言吗?如果只是做一个简单的聊天机器人,显然不需要。但当任务复杂度上升,工具越来越多,输出契约越来越严格时,“语言定义任务契约 + 编译器生成执行产物”这条路的优势会逐渐显现。Linkly 给出的是一个值得认真研究的实现方向:把提示词、工具调用和输出约束收拢到语言和编译流程里,让 LLM 应用从“字符串拼接”走向“契约编译”。顺着这个思路做下去,哪怕不直接采用 MLIR,也会比继续堆提示词模板更接近可维护的工程形态。