欢迎光临
我们一直在努力

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

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

  • 使用 C++23 实现 Prompt DSL 的 Header-Only 解析器:从语法设计到工程落地
    • 摘要
    • 开篇:一个 Prompt,一段 DSL,一次工程化落地
    • 一、Prompt DSL 的背景与发展
      • 1. 从“提示词”到“配置语言”
      • 2. 为什么不是 JSON / YAML?
    • 二、DSL 语法设计(EBNF)
      • 设计原则
    • 三、解析器整体架构
    • 四、词法分析器(Lexer)设计
      • Token 类型
      • 多行字符串处理
    • 五、AST 设计
    • 六、Parser:递归下降的确定性解析
      • 错误处理策略
    • 七、应用场景
    • 八、完整代码(C++23,Header-Only)
    • 结语

摘要

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


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

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

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

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 语法极为克制,仅覆盖必要表达能力。

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”,而不是“写配置”。


三、解析器整体架构

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

  • Lexer(词法分析)
  • AST(抽象语法树)
  • Parser(递归下降解析)
  • 并遵循以下工程目标:

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

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

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

    Token 类型

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

    关键点包括:

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

    多行字符串处理

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

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


    五、AST 设计

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

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

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

    这样设计的优势是:

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

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

    Parser 直接映射 EBNF 规则。

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

    错误处理策略

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

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


    七、应用场景

    该 Prompt DSL 及解析器可用于:

  • 大模型 Prompt 管理系统
  • Agent 行为配置
  • 推理参数模板化
  • IDE / 编辑器插件
  • Prompt 版本化与回滚
  • 在这些场景中,header-only 的 C++ 实现可以轻松嵌入推理引擎或服务端。


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

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

    #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 框架或推理服务,这样的设计可以作为坚实的起点。

    赞(0)
    未经允许不得转载:171主机测评 » 使用 C++23 实现 Prompt DSL 的 Header-Only 解析器:从语法设计到工程落地
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址