使用 C++23 实现 Prompt DSL 的 Header-Only 解析器:从语法设计到工程落地

使用 C++23 实现 Prompt DSL 的 Header-Only 解析器:从语法设计到工程落地

摘要

本文围绕一种用于描述 Prompt 的领域特定语言(DSL),系统性地讲解了其语法设计、历史背景、应用场景,并完整展示了如何使用 C++23 实现一个 header-only 解析器。我们从 EBNF 语法出发,逐步构建词法分析器(Lexer)、抽象语法树(AST)以及递归下降解析器(Parser),最终得到一个可直接嵌入工程、无第三方依赖、支持多行字符串与数值字段的解析方案。文章结尾给出了完整源码,适合用作 Prompt 工程化、配置语言设计以及现代 C++ 解析器实现的参考范例。


开篇:一个 Prompt,一段 DSL,一次工程化落地

在大模型应用逐渐走向工程化的今天,Prompt 已不再只是几行自然语言文本,而是逐步演化为一种结构化配置资产

我们希望这样定义一个 Prompt:

pt 复制代码
prompt MyPrompt {
    system: """
    You are a C++ expert.
    You must answer precisely.
    """

    user: """
    Write a header-only parser in C++23.
    """

    temperature: 0.2
    max_tokens: 512
}

结果是明确的:

  • Prompt 有名字
  • Prompt 内部包含多个字段
  • 字段值可能是字符串、三引号多行文本或数值
  • 文件本身可被程序解析、校验、加载和管理

本文的目标正是如此明确:

设计一套 Prompt DSL,并使用 C++23 实现一个高质量、header-only 的解析器,将文本配置转化为强类型结构。

最终成果是一个可直接使用的解析库,以及一套清晰、可扩展的语言设计思路。


一、Prompt DSL 的背景与发展

1. 从"提示词"到"配置语言"

在早期的大模型使用中,Prompt 往往是硬编码在程序中的字符串。但随着以下需求出现,这种方式逐渐显得力不从心:

  • Prompt 需要版本管理
  • Prompt 需要区分 system / user / assistant
  • Prompt 需要数值参数(temperature、max_tokens)
  • Prompt 需要在运行时加载、校验、组合

这类需求,本质上已经不再是"字符串拼接",而是配置语言问题

2. 为什么不是 JSON / YAML?

自然的疑问是:为什么不直接用 JSON 或 YAML?

原因包括:

  • Prompt 多行文本在 JSON 中可读性极差
  • YAML 语义复杂,缩进规则隐晦
  • 通用格式缺乏 Prompt 语义层面的约束
  • DSL 可以更贴合业务,降低认知成本

因此,一个专用于 Prompt 的 DSL 是合理且高效的选择。


二、DSL 语法设计(EBNF)

本文使用的 Prompt DSL 语法极为克制,仅覆盖必要表达能力。

ebnf 复制代码
document      := prompt_def

prompt_def    := "prompt" identifier "{" field* "}"

field         := identifier ":" value

value         := string
               | number
               | multiline_string

string        := '"' chars '"'
multiline     := '"""' chars '"""'
number        := digit+ | '.'

设计原则

  • 单一入口:每个文件定义一个 prompt
  • 弱类型但可推导:数值自动解析为 double
  • 多行文本友好:三引号直接保留内容
  • 无分号、无逗号:减少噪音

这使得 DSL 在视觉和心理上更接近"描述 Prompt",而不是"写配置"。


三、解析器整体架构

解析器采用经典的三段式结构:

  1. Lexer(词法分析)
  2. AST(抽象语法树)
  3. Parser(递归下降解析)

并遵循以下工程目标:

  • Header-only,方便集成
  • 无异常接口暴露,使用 std::expected
  • 明确 token 类型
  • 明确错误边界

四、词法分析器(Lexer)设计

Lexer 的职责是:
将字符流转换为 Token 流。

Token 类型

cpp 复制代码
enum class TokenKind {
    Identifier,
    Number,
    String,
    MultiString,
    Colon,
    LBrace,
    RBrace,
    Prompt,
    End
};

关键点包括:

  • prompt 是关键字,而非普通标识符
  • 三引号字符串独立为 MultiString
  • 忽略空白符
  • 不支持注释(保持语言最小化)

多行字符串处理

cpp 复制代码
if (c == '"') {
    if (peek(3) == "\"\"\"")
        return multiline();
    return string();
}

这种方式直接保留内容,不做转义解析,适合 Prompt 场景。


五、AST 设计

AST 极简,仅包含 Prompt 与字段映射。

cpp 复制代码
using Value = std::variant<std::string, double>;

struct Prompt {
    std::string name;
    std::unordered_map<std::string, Value> fields;
};

这样设计的优势是:

  • 无多余层级
  • 字段天然可扩展
  • 与 JSON object 结构高度相似

六、Parser:递归下降的确定性解析

Parser 直接映射 EBNF 规则。

cpp 复制代码
prompt := "prompt" identifier "{" field* "}"

错误处理策略

  • 所有失败返回 std::expected<T, std::string>
  • 不抛异常给调用者
  • 错误信息精确定位语义阶段

这在工具链或服务端场景中尤为重要。


七、应用场景

该 Prompt DSL 及解析器可用于:

  1. 大模型 Prompt 管理系统
  2. Agent 行为配置
  3. 推理参数模板化
  4. IDE / 编辑器插件
  5. Prompt 版本化与回滚

在这些场景中,header-only 的 C++ 实现可以轻松嵌入推理引擎或服务端。


八、完整代码(C++23,Header-Only)

以下代码可直接复制编译,包含示例 main

cpp 复制代码
#pragma once
#include <string>
#include <string_view>
#include <vector>
#include <variant>
#include <unordered_map>
#include <optional>
#include <expected>
#include <cctype>
#include <stdexcept>
#include <iostream>

namespace pt
{

    enum class TokenKind {
        Identifier,
        Number,
        String,
        MultiString,
        Colon,
        LBrace,
        RBrace,
        Prompt,
        End
    };

    struct Token {
        TokenKind kind;
        std::string text;
    };

    class Lexer
    {
    public:
        explicit Lexer(std::string_view src) : src_(src) {}

        Token next() {
            skip_ws();
            if (pos_ >= src_.size())
                return {TokenKind::End, ""};

            if (match_keyword("prompt")) {
                return {TokenKind::Prompt, "prompt"};
            }

            char c = src_[pos_];

            if (std::isalpha(c) || c == '_')
                return identifier();
            if (std::isdigit(c))
                return number();

            if (c == '"') {
                if (peek(3) == "\"\"\"")
                    return multiline();
                return string();
            }

            pos_++;
            switch (c) {
                case ':': return {TokenKind::Colon, ":"};
                case '{': return {TokenKind::LBrace, "{"};
                case '}': return {TokenKind::RBrace, "}"};
            }

            throw std::runtime_error("Unexpected character");
        }

    private:
        std::string_view src_;
        size_t pos_{0};

        void skip_ws() {
            while (pos_ < src_.size() && std::isspace(src_[pos_]))
                pos_++;
        }

        std::string peek(size_t n) const {
            if (pos_ + n > src_.size())
                return {};
            return std::string(src_.substr(pos_, n));
        }

        bool match_keyword(std::string_view kw) {
            if (src_.substr(pos_, kw.size()) == kw &&
                !std::isalnum(src_[pos_ + kw.size()])) {
                pos_ += kw.size();
                return true;
            }
            return false;
        }

        Token identifier() {
            size_t start = pos_;
            while (pos_ < src_.size() &&
                  (std::isalnum(src_[pos_]) || src_[pos_] == '_')) {
                pos_++;
            }
            return {TokenKind::Identifier,
                    std::string(src_.substr(start, pos_ - start))};
        }

        Token number() {
            size_t start = pos_;
            bool seen_dot = false;

            while (pos_ < src_.size()) {
                char c = src_[pos_];
                if (std::isdigit(c)) pos_++;
                else if (c == '.' && !seen_dot) {
                    seen_dot = true;
                    pos_++;
                } else break;
            }

            if (src_[start] == '.' || src_[pos_ - 1] == '.')
                throw std::runtime_error("Invalid number literal");

            return {TokenKind::Number,
                    std::string(src_.substr(start, pos_ - start))};
        }

        Token string() {
            pos_++;
            size_t start = pos_;
            while (src_[pos_] != '"') pos_++;
            auto s = std::string(src_.substr(start, pos_ - start));
            pos_++;
            return {TokenKind::String, s};
        }

        Token multiline() {
            pos_ += 3;
            size_t start = pos_;
            while (peek(3) != "\"\"\"") pos_++;
            auto s = std::string(src_.substr(start, pos_ - start));
            pos_ += 3;
            return {TokenKind::MultiString, s};
        }
    };

    using Value = std::variant<std::string, double>;

    struct Prompt {
        std::string name;
        std::unordered_map<std::string, Value> fields;
    };

    class Parser
    {
    public:
        explicit Parser(Lexer lex) : lex_(lex) { advance(); }

        std::expected<Prompt, std::string> parse() {
            if (!consume(TokenKind::Prompt))
                return std::unexpected("Expected 'prompt'");

            auto name = expect(TokenKind::Identifier);
            if (!name)
                return std::unexpected("Expected prompt name");

            if (!consume(TokenKind::LBrace))
                return std::unexpected("Expected '{'");

            Prompt p;
            p.name = name->text;

            while (!check(TokenKind::RBrace)) {
                auto key = expect(TokenKind::Identifier);
                if (!key)
                    return std::unexpected("Expected field name");

                if (!consume(TokenKind::Colon))
                    return std::unexpected("Expected ':'");

                auto val = parse_value();
                if (!val)
                    return std::unexpected("Invalid value");

                p.fields[key->text] = *val;
            }

            consume(TokenKind::RBrace);
            return p;
        }

    private:
        Lexer lex_;
        Token current_;

        void advance() { current_ = lex_.next(); }
        bool check(TokenKind k) const { return current_.kind == k; }

        bool consume(TokenKind k) {
            if (check(k)) { advance(); return true; }
            return false;
        }

        std::optional<Token> expect(TokenKind k) {
            if (!check(k)) return std::nullopt;
            Token t = current_;
            advance();
            return t;
        }

        std::optional<Value> parse_value() {
            if (check(TokenKind::String) ||
                check(TokenKind::MultiString)) {
                auto t = current_;
                advance();
                return t.text;
            }
            if (check(TokenKind::Number)) {
                auto t = current_;
                advance();
                return std::stod(t.text);
            }
            return std::nullopt;
        }
    };

} // namespace pt

int main() {
    char const* src = R"(
        prompt Demo {
            system: """You are a compiler."""
            temperature: 0.3
            max_tokens: 256
        }
    )";

    pt::Parser parser{pt::Lexer{src}};
    auto result = parser.parse();

    if (!result) {
        std::cerr << result.error() << "\n";
        return 1;
    }

    std::cout << "Prompt name: " << result->name << "\n";
    for (auto const& [k, v] : result->fields) {
        std::cout << k << ": ";
        std::visit([](auto const& value) {
            std::cout << value;
        }, v);
        std::cout << "\n";
    }
}

结语

一个小而美的 DSL,加上一个清晰、克制的解析器,往往比引入复杂框架更具长期价值。

如果你正在构建 Prompt 系统、Agent 框架或推理服务,这样的设计可以作为坚实的起点。

相关推荐
shangjian0075 小时前
AI-大语言模型LLM-模型微调3-Prompt Tuning
人工智能·语言模型·prompt
Bruk.Liu1 天前
AI中的Agent、Prompt、MCP与Function Calling:从简单对话到智能执行
人工智能·prompt·mcp
猫头虎2 天前
中国开源大模型霸榜全球:全球开源大模型排行榜前十五名,全部由中国模型占据
langchain·开源·prompt·aigc·ai编程·agi·ai-native
坠金2 天前
prompt
prompt
花间相见3 天前
【LangChain】—— Prompt、Model、Chain与多模型执行链
前端·langchain·prompt
qiukapi3 天前
四. Model I/O 之 Prompt Template
prompt·prompttemplate
Familyism3 天前
Prompt概述
prompt
加加今天也要加油3 天前
Oinone × AI Agent 落地指南:元数据即 Prompt、BPM 状态机护栏、SAGA 补偿、GenUI
人工智能·低代码·prompt
问道飞鱼3 天前
【大模型学习】提示词工程(Prompt Engineering)技术深度报告
学习·prompt·提示词