使用 C++23 实现 Prompt DSL 的 Header-Only 解析器:从语法设计到工程落地
- [使用 C++23 实现 Prompt DSL 的 Header-Only 解析器:从语法设计到工程落地](#使用 C++23 实现 Prompt DSL 的 Header-Only 解析器:从语法设计到工程落地)
- 摘要
- [开篇:一个 Prompt,一段 DSL,一次工程化落地](#开篇:一个 Prompt,一段 DSL,一次工程化落地)
- [一、Prompt DSL 的背景与发展](#一、Prompt DSL 的背景与发展)
- [1. 从"提示词"到"配置语言"](#1. 从“提示词”到“配置语言”)
- [2. 为什么不是 JSON / YAML?](#2. 为什么不是 JSON / YAML?)
- [二、DSL 语法设计(EBNF)](#二、DSL 语法设计(EBNF))
- 三、解析器整体架构
- 四、词法分析器(Lexer)设计
- [Token 类型](#Token 类型)
- 多行字符串处理
- [五、AST 设计](#五、AST 设计)
- 六、Parser:递归下降的确定性解析
- 七、应用场景
- 八、完整代码(C++23,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",而不是"写配置"。
三、解析器整体架构
解析器采用经典的三段式结构:
- Lexer(词法分析)
- AST(抽象语法树)
- 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 及解析器可用于:
- 大模型 Prompt 管理系统
- Agent 行为配置
- 推理参数模板化
- IDE / 编辑器插件
- 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 框架或推理服务,这样的设计可以作为坚实的起点。