jinja.cpp:为什么我要手写一个 Jinja2 编译器

1. 源起:从字符串拼接到模板引擎

这个项目的灵感来源于我个人在 LLM 推理框架开发的经历。

回到 2023 年初,当时我开始在 MNN 上支持第一个 LLM 模型——ChatGLM-6b。那个时候,LLM 的生态还在爆发初期,每一家模型厂商(甚至同一个厂商的不同版本)都在自定义 Prompt 格式。

记得最早的时候,Prompt Template 还是通过简单的字符串拼接实现的(往往是参考 Python 代码中的拼接逻辑硬翻译成 C++)。例如,在 ChatGLM-6b 的 stream_chat 函数中,我们可以看到这样的代码:

    if not history:
        prompt = query
    else:
        prompt = ""
        for i, (old_query, response) in enumerate(history):
            prompt += "[Round {}]\n问:{}\n答:{}\n".format(i, old_query, response)
        prompt += "[Round {}]\n问:{}\n答:".format(len(history), query)

为了在 C++ 中支持它,我们需要手动实现类似的 [Round 0] ... 拼接逻辑;而为了支持 Llama,又得写一套 [INST] 的逻辑。虽然这种方式简单粗暴,但在早期模型结构单一的时代,它确实有效且高效。

痛点出现

随着 LLM 技术的高速迭代,情况发生了变化:

  1. 标准化趋势:HuggingFace Transformers 库确立了使用 Jinja2 作为 chat_template 的标准描述语言,主流开源模型(Llama 3, Qwen 2.5, DeepSeek 等)纷纷跟进。
  2. 复杂度爆炸:模型能力不再局限于简单的对话。
    • CoT (思维链):需要特定的特殊标记(如 DeepSeek-R1 的 thinking tokens)。
    • Tool Calling (工具调用):需要在 Prompt 中动态插入复杂的 JSON 结构、函数定义,并处理模型对工具调用的回复。
    • 多模态 (Multimodal):需要处理图像、视频等占位符。

面对这些需求,原来的硬编码字符串拼接方案显得捉襟见肘。每接入一个新模型,就需要深入理解其 Prompt 结构并编写大量易错的 C++ 代码,维护成本极高。

我开始意识到,我们需要在 C++ 端直接解析和渲染 Jinja2 模板。

为什么造轮子?

虽然现在也有一些开源项目(比如 Jinja2Cpp, minja),但在调研后我发现,现有的方案要么易用性、部署要求等不满足要求,要么对 LLM 特有的场景(如 Python 风格的 tojson 格式化、特殊空白控制)支持不足。

我的目标非常明确:做一个最小化、轻量级、且专门针对 LLM 场景覆盖度广的 Jinja2 C++ 实现。

这就是 jinja.cpp 的诞生初衷:Single Header, Zero Dependency (except json)

(MNN 中目前使用的是改造版的 minja,未来会切换到 jinja.cpp)

2. LLM Chat Template 中的关键语法

为了实现这个子集,首先需要梳理 LLM 模板中到底用到了哪些 Jinja2 语法。与其追求 100% 的 Jinja2 兼容,不如追求 100% 的 HuggingFace Chat Template 覆盖

经过分析,以下语法是必须要支持的“核心子集”:

  • 变量输出{{ messages }}
  • 控制流
    • {% for message in messages %}:遍历消息历史。
    • {% if message.role == 'user' %}:判断角色或条件。
  • 变量设置{% set system_message = ... %}
  • 宏定义 (Macro):很多复杂模板(如 Qwen)喜欢用 {% macro %} 来封装格式化逻辑。
  • 过滤器 (Filters)
    • | trim:去除空白。
    • | tojson这是最核心的,用于序列化工具描述或消息内容,且格式必须与 Python json.dumps 行为一致。
  • 空白控制 (Whitespace Control){%- ... %}{{- ... }}
    • 在普通的 Jinja2 模板中,我们可能不太在意多一个或少一个换行。但在 Prompt Engineering 中,每一个 token 都至关重要
    • 左侧剔除 ({%-):剔除该标签 左侧(即它前面)紧邻的所有空白字符(包括换行符)。例如 Text \n {%- if ... 会变为 Text{%- if ...(渲染时无换行)。
    • 右侧剔除 (-%}):剔除该标签 右侧(即它后面)紧邻的所有空白字符。
    • 重要性:例如 ChatML 格式通常要求 <|im_start|> 严格换行或紧贴。jinja.cpp 严格实现了这一行为,确保渲染出的 Prompt 与官方 Python 库字节级一致,避免因多余空行导致模型幻觉或停止词失效。
  • 测试 (Tests){% if variable is defined %}

3. 技术实现:构建轻量级引擎

词法与语法分析 (Lexer & Parser)

为了保持轻量,我没有使用 Bison/Flex 或 ANTLR 等生成器,而是手写了一个简单的 递归下降分析器 (Recursive Descent Parser)

  • Lexer (词法分析):不仅要识别 Token,最关键的是处理 Jinja 的空白控制策略 (lstrip_blocks, trim_blocks)。我在 Lexer 层面维护了状态机,当遇到 {{- 时,会回溯清除前一个文本 Token 的尾部空白;遇到 -}} 时,标记下一个文本 Token 清除前导空白。
  • Parser (语法分析):处理表达式优先级是难点。例如 if not variable and condition。我实现了一个标准的 Pratt Parser 或者说优先级攀爬 (Precedence Climbing) 算法来正确处理二元和一元运算符。

AST (抽象语法树) 设计

AST 的设计直接映射了 Jinja 的语义结构。

  • 基类 Node:定义了虚函数 render(Context&, string& out)
  • 控制节点
    • ForStmt:保存循环变量名、迭代表达式、循环体。渲染时,它会创建一个新的作用域 (Scope) 并推入 Context
    • IfNode:保存条件表达式、True 分支、False 分支。
    • MacroNode:保存参数列表和函数体。宏调用本质上是函数调用。
  • 表达式节点 (Expr)
    • 所有数据统一使用 nlohmann::json 存储,这大大简化了 C++ 的类型系统负担。
    • GetAttrExpr / GetItemExpr:统一处理 obj.attrobj['attr']

核心函数与生态

为了支持 LLM 模板,仅仅实现语法是不够的,必须实现生态中的标准函数:

  • namespace:Jinja2 中的 set 变量默认只在当前块作用域有效。在 for 循环中修改外部变量需要使用 namespace 对象。
    • 例如:{% set ns = namespace(found=false) %},然后在循环中 {% set ns.found = true %}
  • tojson 的魔改:Python 的 json.dump 会在分隔符后加空格,而有些 C++ JSON 库默认不加。为了保证 Prompt 的绝对一致性,我重写了 JSON 序列化逻辑,并特别支持了 Key 的特定顺序排序(type -> function -> name…),因为很多模型是过拟合了特定的 JSON Key 顺序的。

4. 测试驱动开发:以模型为中心

作为一个兼容性项目,验证其正确性的最佳标准不是单元测试,而是 “对齐测试”

我设计了一套基于 HuggingFace Transformers 的测试流程:

  1. Golden Data 生成:编写 Python 脚本,使用官方 transformers 库加载主流模型(Qwen, Llama, DeepSeek 等),针对这几十个模型,分别生成各种边缘 Case(含 System Prompt、无 System Prompt、含 Tools、Multi-turn 等)的标准输出结果。
  2. C++ 验证:在 C++ 端加载同样的模板字符串,输入同样的 nlohmann::json 数据,对比渲染结果字符串。
  3. 模糊匹配:针对 strftime_now 等动态时间函数,测试框架支持正则化的模糊匹配,确保时间差异不影响测试通过。

目前,项目已经通过了包括 Qwen 2.5/3 全系列、Llama 3/3.2 全系列、DeepSeek V3/R1、Mistral、Gemma 等 30+ 个主流模型的模板测试。

5. 结语

jinja.cpp 不仅仅是一个模板引擎,它是 LLM 推理工程化演进的一个缩影。

从最初的硬编码字符串拼接,到现在的标准化模板渲染,能够看到整个 AI 基础设施正在变得越来越通用和规范。本项目希望以最精简的代码(Single Header!),填补 C++ 在这一领域的空白。

现在,你无需引入庞大的依赖,也无需为此编写繁琐的逻辑。现在,你只需要将 jinja.hpp 扔进你的 C++ 工程,就能立刻拥有完整的、对齐 HuggingFace 标准的 Prompt 生成能力。

希望这个小巧的工具,能成为你 LLM 开发工具箱中趁手的那一把。

项目地址:https://github.com/wangzhaode/jinja.cpp




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • LLM Super Weight 实测:剪枝降智与量化思考
  • MNN支持Eagle3
  • LLM训练实战手册
  • MNN模型支持:Qwen3-VL
  • 一图读懂Qwen