【每日一题】Python 编程题:Prompt 模板渲染与占位符残留检测(附题解,代码已跑通)

题目:AI 应用里的 Prompt 通常由「模板 + 变量」渲染后再发给大模型。渲染环节的常见事故是:变量漏传导致 {{price}} 原样发给模型(模型会照着占位符输出),或者多传了模板里根本没有的变量(说明模板被改了但调用方没同步)。

请实现 render_prompt(template, variables),返回三元组 (rendered, missing, extra):

  • template:字符串,占位符写作 {{key}},key 由字母、数字、下划线组成,大括号内侧允许有空格,如 {{ key }};
  • rendered:把能在 variables 中找到的占位符替换成对应值(值统一用 str() 转换)后的字符串;找不到的占位符原样保留(保留原始写法);
  • missing:模板中出现、但 variables 里没有的 key,按首次出现顺序去重;
  • extra:variables 中提供、但模板从未使用的 key,按字典序升序;
  • 不满足 key 规则的写法(如 {abc}、{{!!}}、未闭合的 {{x)一律不算占位符,原样输出;
  • 替换后的值内部即使含有 {{y}} 这类形态,也不允许被二次替换。

代码框架:

def render_prompt(template: str, variables: dict) -> tuple:
    """渲染 Prompt 模板并检测占位符异常。

    Args:
        template: 含 {{key}} 占位符的模板字符串。
        variables: 变量字典。

    Returns:
        (rendered, missing, extra)
        rendered: 渲染结果,未命中的占位符原样保留。
        missing:  模板用到但没传值的 key,按首次出现顺序去重。
        extra:    传了但模板没用到的 key,按字典序升序。
    """
    pass

断言用例:

assert render_prompt("你好 {{name}},欢迎回来", {"name": "小明"}) == \
    ("你好 小明,欢迎回来", [], [])

assert render_prompt("{{ a }}买{{b}}元", {"a": "苹果"}) == \
    ("苹果买{{b}}元", ["b"], [])

assert render_prompt("{{m}}-{{n}}-{{m}}", {}) == \
    ("{{m}}-{{n}}-{{m}}", ["m", "n"], [])

assert render_prompt("plain text", {"b": 2, "a": 1}) == \
    ("plain text", [], ["a", "b"])

assert render_prompt("价格 {{price}} 元", {"price": 0}) == \
    ("价格 0 元", [], [])

assert render_prompt("{abc} {{d}}", {"d": "x"}) == \
    ("{abc} x", [], [])

assert render_prompt("{{n}}", {"n": "a\\b{c}"}) == \
    ("a\\b{c}", [], [])

# 关键:值里的 {{y}} 不能被二次替换
assert render_prompt("{{x}}{{y}}", {"x": "{{y}}", "y": "V"}) == \
    ("{{y}}V", [], [])

:point_right:参考答案/答题要点:

思路一:暴力解 —— 手写逐字符扫描

一次线性扫描定位 {{ 和 }},用切片重建结果,天然只扫原文、不会碰到已替换内容。

import re

PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
KEY = re.compile(r"[A-Za-z0-9_]+")

def render_prompt_brute(template, variables):
    parts, missing, seen = [], [], set()
    i, n = 0, len(template)
    while i < n:
        if template[i:i + 2] == "{{":
            j = template.find("}}", i + 2)
            if j != -1:
                key = template[i + 2:j].strip()
                if key and KEY.fullmatch(key):
                    if key in variables:
                        parts.append(str(variables[key]))
                    else:
                        parts.append(template[i:j + 2])   # 原样保留
                        if key not in seen:
                            seen.add(key)
                            missing.append(key)
                    i = j + 2
                    continue
        parts.append(template[i])
        i += 1
    used = {m.group(1) for m in PLACEHOLDER.finditer(template)}
    return "".join(parts), missing, sorted(k for k in variables if k not in used)

思路二:优雅解 —— re.sub 回调一次搞定(推荐)

import re

PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")

def render_prompt(template, variables):
    missing, seen = [], set()

    def _sub(match):
        key = match.group(1)
        if key in variables:
            return str(variables[key])
        if key not in seen:
            seen.add(key)
            missing.append(key)
        return match.group(0)          # 未命中:原样保留

    rendered = PLACEHOLDER.sub(_sub, template)
    used = {m.group(1) for m in PLACEHOLDER.finditer(template)}
    extra = sorted(k for k in variables if k not in used)
    return rendered, missing, extra

时间复杂度 O(n + m),n 是模板长度、m 是变量个数(只为排序);一次扫描同时完成替换和缺失收集。

易踩的第三种写法(错误示范):很多人会写成「遍历 variables,逐个 re.sub 或 str.replace」。这种写法会重复扫描已经渲染过的字符串,一旦某个值本身含占位符形态就会被二次替换:

def render_prompt_wrong(template, variables):
    r = template
    for k, val in variables.items():
        r = re.sub(r"\{\{\s*" + re.escape(k) + r"\s*\}\}", str(val), r)
    return r

render_prompt_wrong("{{x}}{{y}}", {"x": "{{y}}", "y": "V"})   # 实际得到 'VV',期望 '{{y}}V'

采分点:

  1. 正则写对:\{\{\s*([A-Za-z0-9_]+)\s*\}\} —— 支持内侧空格;写成 {{.+?}} 这类贪婪/通配版本,会把 {abc} {{d}} 或跨占位符内容错误吞掉,直接扣分。
  2. 不做二次解析:必须从原始模板出发一次性扫描。用「循环替换已渲染结果」的写法,值里含 {{y}} 时会被连环替换(上面错误示范实测输出 VV 而非 {{y}}V)。re.sub 传函数回调,或逐字符扫描,都能天然规避。
  3. missing 的顺序与去重:要求「首次出现顺序」,用 set 判重 + list 保序;返回 set 会被判错(丢了顺序信息)。
  4. extra 的判定基准:要和「模板里真实出现过的 key」比较,而不是和「成功替换掉的 key」比较。{{x}} 传了 x 和 y,y 才是 extra;漏传 x 时 missing=["x"]。
  5. extra 要排序:字典遍历顺序不可靠,必须显式 sorted。
  6. ** falsy 值不能当缺失**:{"price": 0} 是合法传值,要渲染成 "价格 0 元"。用 variables.get(key) 再判 if not value 就出 bug,必须用 key in variables。
  7. 非法写法不吞:{abc}、未闭合的 {{x 要原样输出,正则用严格的 key 字符集天然满足。
  8. 替换值里的反斜杠:用 re.sub(pattern, str(val), s) 传字符串替换,值里有 \1、\g<0> 会被当成分组引用出错;传函数回调或 lambda 则安全。
  9. 加分:主动补一句工程做法 —— 渲染完再跑一次 PLACEHOLDER.findall(rendered) 兜底,非空即拦截并报警,这才是这条逻辑在生产里真正防事故的地方;单测要覆盖「漏传、多传、空串、值为 None/0/False、值含花括号」五类。