
在 Ask HN 上看到一个问题Can trivial LLM calls be compiled into conventional data pipelines翻译成日常开发语言就是在问那些看起来很简单的 LLM 调用能不能像 SQL 一样被编译成一条可重复执行、可调度、可回溯的数据管道。这个问题值得认真回答。不是因为它看起来前沿而是因为太多 LLM 项目都卡在中间状态几十条数据时脚本很顺畅到几千条几万条时开始频繁失败换了一个 prompt历史结果全部作废加了一个字段要手动处理一批脏输出API 偶尔超时你要在日志里找一个中途失败的任务却没有任何断点续跑机制。我的判断是可行但“编译”这个词容易误导。真正要做的不是把 LLM 调用变成某种底层机器码而是把无序的、有概率行为的模型调用包装成传统数据管道里的有边界算子。这个中间层吸收不确定性同时把可控性还给工程系统。接下来我会分别解释为什么要这样理解、具体怎么做、边界在哪。1. 琐碎的 LLM 调用为什么值得被认真对待一个“琐碎”的 LLM 调用有多简单大概就是把一段文本拆进 prompt调用模型 API拿到一段 JSON 输出。看起来像一个普通函数调用很多脚手架甚至不建议为它单独设计模块。但工程上琐碎和简单不是一回事。琐碎指的是单个调用很小、很容易理解但这类调用一旦出现在批量任务里就会被放大成一批非常棘手的问题。你可以把它类比成物流里的“最后一公里”单件快递的配送看起来不难真正难的是规模、时效、异常件和整体调度。1.1 一句 prompt 背后不只是一个 HTTP 请求很多人第一次做 LLM 批量任务时最容易低估两点。第一结果不稳定。同一个 prompt同一个输入两次调用可能给出不同的格式。温度调到 0 也不能完全消除抖动模型版本更新、推理引擎不同、甚至部署精度不同都可能让输出产生细微变化。社区里关于 FP16、FP32、BF16 的讨论看着像模型部署问题实际上也会传导到下游你以为是同一个模型跑出来的 JSON 可能多一个空格、少一个字段导致下游解析直接失败。第二成本和时间不再是普通函数调用级别。一次 LLM 调用的耗时是几十毫秒到几秒token 费用也和文本长度相关。批量任务里一个循环处理一万条数据调用时间从几分钟变成几小时成本从零散几分钱变成明确预算。这时候很多在单次调用里完全合理的写法在批量场景里就没有位置了。所以“琐碎调用”只是从业务逻辑角度说的放到执行系统里它是一种有外部依赖、有概率行为、有资源成本的远程任务。1.2 传统数据管道和 LLM 调用为什么像两个物种传统数据管道也就是 ETL、DAG、批处理这套体系默认以下模型算子处理确定性的数据输入相同、输出相同失败可以重试任务可以回放运行过程可以被监控和审计。LLM 调用正好相反输入相同、输出不一定相同失败可能是网络、限流、也可能是模型输出了不符合预期的内容重放一条 token 可能需要重新计费prompt 改了之后历史结果无法直接和现在的产物对比。把 LLM 调用直接塞进传统管道等于要求一个不稳定的算子满足稳定管道的所有承诺。表面上能跑但一旦出问题无法定位。更理性的思路是承认 LLM 调用的概率性然后给它加一层“约束”明确输入输出 schema校验输出缓存结果失败重试版本管理。这个约束层就是问题里“编译”两个字真正指向的东西。它不是消除 LLM 的不确定性而是把不确定性关进一个笼子里。2. “编译”不是玄学从 LLM 调用到管道执行计划一个大胆但实用的类比是 SQL。你在数据库里写 SQL不会指定查询该走哪个索引、要不要并行、连接顺序是什么。数据库收到 SQL 后会先解析、校验、优化最终生成一个执行计划。这个计划是传统意义上的“编译产物”但不是机器码而是一棵可落地的算子树。LLM 调用如果也能用声明式方式描述就能由引擎生成类似的执行计划。你可以不写for item in items: call_llm(item)而是写“我要对这批数据做抽取、校验、聚合、落库”由引擎决定怎么分批、怎么重试、怎么缓存。2.1 编译到执行计划SQL 早就这么做过了这个隐喻有很强的解释力。声明式的好处在于你不用在每个脚本里重复实现并发、重试、日志。你只需要表达业务意图。底层可以做静态检查prompt 模板是否存在模型名是否支持输出字段是否和下游 schema 对齐可以自动优化把相邻的小输入合成批量请求控制 token 上限按并发配额调度可以做成本预算记录每次调用的 token估算整体费用超预算就暂停。这些能力在传统管道里不稀奇但 LLM 调用场景下很难靠手写循环获得。手写循环的起点很低终点却很快被“重复胶水代码”锁住每次需求变化都要改一片脚本且改完无法确认会影响哪些历史任务。2.2 把 LLM 算子映射到管道原语要让 LLM 调用进入管道第一步是把它拆成标准原语。LLM 任务元素对应数据管道概念输入数据集CSV、表、消息队列source 源算子将输入渲染为 prompt 并调用模型map 算子输出 json.loads 与 schema 校验validate / filter 算子低置信度或未通过校验的输出dead-letter / 人工复核分支结果写回数据库或对象存储sink 算子记录输入 hash、模型版本、token、时间telemetry / audit log这个映射看起来简单但价值很大。一旦把 LLM 调用定义为“一个符合输入输出契约的 map 算子”传统管道里的调度、重试、监控、回放就都能复用。你会突然发现自己不是在写一个又一个脚本而是在搭建一条能长期维护的流水线。2.3 最小示例声明式 DSL 生成可执行管道下面用一个 YAML 结构展示这个思路。它不是某个现成库的语法只是表达一种常见实现方式pipeline: name: comment_sentiment_extract source: type: read_csv path: ./comments.csv columns: [id, text] steps: - operator: llm_call task: extract_sentiment prompt: templates/extract_sentiment.j2 model: text-sentiment-v3 parameters: temperature: 0 max_tokens: 300 output_schema: type: object properties: sentiment: { type: string, enum: [positive, negative, neutral] } keywords: { type: array, items: { type: string } } required: [sentiment, keywords] - operator: validate_json error_action: dead_letter - operator: write_database target: analytics.comment_sentiment mode: upsert retry_policy: max_retries: 3 backoff: exponential retry_on: [timeout, rate_limit, 5xx] observability: log_each_call: true cache_enabled: true cache_key: [model, prompt_version, input_hash]这段配置表达的是一个非常典型的“琐碎 LLM 任务”对每条评论做情感和关键词抽取校验输出 JSON最终写入数据库。如果没有编译层你要自己实现 CSV 读取、循环、错误处理、重试、写库、日志有了编译层这些横切能力会成为一个通用执行器的职责。会有人问这不就是低代码或 workflow 引擎吗也对。只是在 LLM 场景下执行器需要额外处理几个普通 workflow 不常碰的问题输出是否合法、模型版本是否固定、prompt 模板如何做回归、token 成本怎么控制。这些问题的答案不应该散落在每个业务脚本里而应该沉淀到管道语法和引擎策略里。3. 单次跑通不算数批量管道化的四个关键坑我经常看到团队把脚本从单次调用改成批量任务以为只是加一层循环结果跑起来之后才发现问题远比自己想的多。这一节不是理论而是几个非常实际的关键点。3.1 问题一确定性缺失输出校验不能只在事后做LLM 管道里你最大的敌人不是“返回错误”而是“返回很像正确但不符合 schema 的内容”。以刚才的情感抽取为例你要求输出 JSON但模型可能返回一段带解释的文字可能返回数组而不是对象可能漏掉 keywords 字段可能在中途把多个结果合并到一条输出里。单次调用时肉眼能看出来批量任务里一条输出异常可能让整个下游表出现脏数据。应对办法是把校验放在两个位置一是调用后立即做 schema 校验二是写入下游前再做一次数据质量检查。校验失败的记录进入死信表而不是直接丢弃。这样你可以定期审视是 prompt 问题、模型问题、还是输入数据本身有噪声。另外schema 校验字段不能太松。additionalProperties: false在最初调试时很碍事但在生产任务里能救你一命你不会因为一个小样本输出正常就假设所有输入都能映射到同一个 JSON 结构。3.2 问题二失败重试与限流不能依赖裸循环批量调用 LLM API 时最常见的失败有三类网络抖动、限流、模型返回空/异常。裸 for 循环对这三件事都无能为力。限流后继续循环会造成更多 429网络抖动后不重试一个小时内要手动盯屏补跑输出异常后没有退出机制会让下游表写进一堆空片段。一个相对稳妥的做法是先确认上游 API 的配额和限流模型设置最大并发与每秒请求数重试使用指数退避重试次数不要超过 3 到 5 次把连续失败超过阈值的任务停掉把批任务标记为failed失败原因要分类timeout、rate_limit、bad_output、auth_error不要全部当成同一类异常。不要一出错就全量重跑。真正可重跑的粒度是一条记录或一批记录而不是整个任务。如果中间 30% 的记录已经成功并写库再次全量重跑会产生重复数据。用唯一主键、幂等写入才能让“重试”这件事真正成立。3.3 问题三缓存、重放和可观测性如果任务要反复跑缓存的收益很大。相同的输入、相同的 prompt 版本、相同的模型参数理论上可以复用之前的输出省一笔 token。但缓存 LLM 输出有风险模型更新、prompt 改了一个字、温度参数改变旧缓存就不该再用。通常的做法是缓存 key 包含模型名、模型版本、prompt 版本、参数哈希、输入文本哈希命中缓存时不要盲目信任仍要做 schema 校验。可观测性方面每个 LLM 调用都值得记录输入文本 hash、prompt 版本、模型、参数、token 数、耗时、返回状态、校验结果。没有这些信息后续排查只能靠猜。传统管道里大家习惯看日志LLM 管道里日志还要补上“语义层面的采样”每几百条里随机抽查几条输入输出判断整体质量是否退化。3.4 问题四prompt 迭代与 schema 演进传统数据管道的 schema 变化往往走 migrationLLM 管道的 prompt 变化也该走类似流程。改 prompt 不是“改个字符串”而是改了一个会改变全量输出分布的行为参数。我建议把 prompt 模板当作代码管理变更要有版本号提交要配一个小样本 golden set。golden set 是一组固定输入及期望输出每次改 prompt 后先跑一遍看有多少条通过校验、多少条语义偏差过大。如果没有这个验证环节prompt 改了以后历史数据和新的结果都失去可比性。schema 演进也一样。新增一个输出字段时要兼容旧记录删除一个字段时要确认下游没有依赖。这些工作看似死板但能让你在三个月后不需要“重新跑一遍才知道发生了什么”。在问题排查时我通常按这个顺序定位先看日志里是调用失败还是校验失败调用失败再看是网络、限流还是模型错误校验失败再取一条输入 prompt 做人工复现确认是 prompt 问题还是输入数据问题最后才怀疑模型或部署层。这个链路能帮你快速收敛而不是一上来就调温度参数。4. 不是所有 LLM 任务都适合管道化边界与判断清单前面讲了那么多“可行”和“怎么做”但这个问题更准确的答案其实是一个边界判断。把 LLM 调用编译进数据管道不是银弹。4.1 适合管道化的 LLM 任务批量、固定、可重放最适合管道化的是那些“输入集合可枚举、目标明确、输出可以被 schema 约束”的任务。典型例子包括从商品评论中抽取情感、关键词、问题分类把非结构化邮件、工单转成结构化字段为知识库批量生成摘要、文本向量对一批文档做脱敏、敏感信息识别定时对爬取下来的网页做分类和实体抽取。这些任务有一个共同点它们本质上是“数据加工”。模型是被当作一个复杂的算子在使用而不是一个对话伙伴。管道编译后你可以获得批处理、重试、可观测性、断点续跑这些收益远超额外引入的配置成本。4.2 不适合管道化的任务Agent、长对话、实时交互与之相对另一些 LLM 应用天然不适合传统批式管道。交互式 Agent 需要在多轮中动态决定下一步工具调用。虽然从高层看Agent 的循环也可以被描述成管道但它每一步的输入都依赖上一步模型输出无法提前枚举更关键的是它的语义不是“处理一批数据”而是“在不确定的状态空间里逐步逼近目标”。这时硬套传统管道只会得到一个僵硬的调度逻辑失去动态决策能力。长对话、实时聊天、在线代码补全等延迟敏感任务也不适合先写进批管道再执行。它们更依赖流式、事件驱动架构。RAG 是一个有意思的中间地带索引和召回阶段非常适合管道化但“生成回答”阶段如果涉及多跳检索和工具调用则更接近 Agent 场景。这也是为什么出现了很多“编排框架”而不是单一管道引擎——不同层级的任务需要不同的控制流表达方式。现在社区里讨论 LLM 应用为什么需要编排框架本质也是在这个问题里寻找合适的抽象层级。4.3 一个可复用的判断清单在决定要不要把某个 LLM 功能“编译”成管道之前用下面这张清单做一次自检判断项偏向管道化偏向交互式实现输入数据是否可提前枚举是来自表或文件否用户实时输入单次调用的结果是否可校验是有明确 schema否依赖对话上下文是否允许重试和延迟是分钟级都可接受否需要秒级响应是否有明确的成本预算是可按 token 做出预算否弹性较大成本难预判是否需要复跑历史数据是需要版本对比通常不需要是否需要人工介入低频只处理死信高频本身就是交互如果答案大部分落在左边管道化大概率值得投入。如果大部分落在右边先不要急着抽象做成可观测的服务或 Agent 循环也许更自然。5. 真正的变化不在“编译”而在“约束化”现在可以回答最初的问题了琐碎的 LLM 调用能不能编译成传统数据管道能但更准确地说是被“约束化”成管道算子。整个迁移过程不要求你把 LLM 变得像 SQL 一样确定性十足而是要求在你和模型之间加一层契约输入契约、输出契约、失败契约、成本契约。这层契约才是“编译”这个隐喻里真正有价值的部分。5.1 从“调用”到“声明式描述”的抽象跃迁传统开发习惯里调用 LLM 就是写代码。openai.chat.completions.create(...)很简单但它在系统中是一个没有边界的动作直接依赖网络、模型版本、prompt 字符串、上下文内容。一旦业务复杂你会发现很难在多个脚本之间复用同一个 LLM 能力。声明式描述则不同。你写的是“这条管道要对什么数据做什么转换”而不是“具体怎么循环、怎么重试、怎么写库”。执行引擎负责把描述编译成可执行计划。今天很多 LLM 编排框架都在往这个方向走从 LangChain 到 Spring AI再到 RAG、MCP、Agent 等概念都是在提供不同粒度的抽象。差异只在于有些偏重动态编排有些偏重确定性管道有些偏重开发体验有些偏重生产稳定性。5.2 开发者要补上的数据工程思维这个变化对开发者提出的要求不是“学会某个新框架”而是换一种思考方式在写第一个 LLM 调用之前先想清楚输入输出契约。你可以从一个小习惯开始每个 LLM 调用都定义一个输入数据类和一个输出数据结构每次调用都用同一个函数入口把模型、prompt、参数作为可配置项每批任务都写一个结果报告成功数、失败数、校验通过率、token 消耗每条失败记录都进入死信表而不是静默丢弃。每条失败记录都应该进入死信表而不是沉默丢弃。这些看起来像是数据工程的老常识但在 LLM 项目里却经常被忽略。原因很简单刚起步时单次调用的成功率太高高到你觉得不需要这些防御一旦进入批量阶段失败被放大你才意识到缺了整条工程链路。5.3 先跑通再编译最后才是“管道化”如果让我给你一个落地建议我会说不要从第一天就开始设计大型 DSL。先用最愚蠢的方式跑通一条样本输入一条数据调用模型校验输出记录结果。然后把它变成可以重复执行的函数加入重试和日志。等到你真的需要处理 1000 条、10000 条数据并且需要反复重跑时再开始“编译”出管道。这个顺序和传统软件工程里“先优化再做抽象”是一致的。只不过 LLM 场景下很多人会被泛化出来的成本吓到一个简单的调用为什么要引入配置、schema、缓存、死信因为你面对的不再是“一条数据”而是一整条长期运行的流程。把一次性操作沉淀成可复用流程本身就是从脚本走向系统的关键一步。理解这一点比争论“能不能编译”更有价值。下一次当你发现自己要在一个 for 循环里调用 LLM 时先别急着写代码。停下来用三分钟把输入、输出、失败重试和记录日志画出来。这就是你第一条 LLM 数据管道的起点。