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

相关推荐
王解15 小时前
第五篇:与 LLM 对话 —— 模型接口封装与 Prompt 工程
prompt·nanobot
XLYcmy16 小时前
智能体大赛 总结与展望 比赛总结
大数据·ai·llm·prompt·agent·qwen·万方数据库
光的方向_21 小时前
ChatGPT提示工程入门 Prompt 03-迭代式提示词开发
人工智能·chatgpt·prompt·aigc
XLYcmy2 天前
智能体大赛 实现逻辑 大容量数据预处理机制
ai·llm·json·prompt·api·检索·万方数据库
XLYcmy2 天前
智能体大赛 实现逻辑 “检索先行”的闭环工作流
数据库·ai·llm·prompt·agent·rag·万方
AI Echoes2 天前
对接自定义向量数据库的配置与使用
数据库·人工智能·python·langchain·prompt·agent
大好人ooo2 天前
Prompt 工程基础方法介绍
prompt
XLYcmy3 天前
智能体大赛 核心功能 惊喜生成”——创新灵感的催化器
数据库·ai·llm·prompt·agent·检索·万方
dingdingfish4 天前
Bash学习 - 第6章:Bash Features,第9节:Controlling the Prompt
prompt·bash·ps1
CeshirenTester4 天前
9B 上端侧:多模态实时对话,难点其实在“流”
开发语言·人工智能·python·prompt·测试用例