Dotprompt 与结构化输出:让模型吐出可校验的类型
30 秒导读: 这一章讲 Genkit 怎么把「提示工程」和「结构化输出」都做成一等公民。 一端:把一段 Handlebars 模板(
.prompt文件或definePrompt)编译成一个能像函数一样调用的 executable-prompt,渲染出messages;另一端:用一套 format 插件(json / array / jsonl / enum / text)往提示里注入格式指令,再在(流式)输出上做健壮解析——哪怕模型吐的 JSON 残缺,也尽量抠出可用的类型。
本章聚焦「输入模板化 + 输出结构化」这两端。模型调用本身、以及自动工具循环,见 03-generate-and-tool-loop.md;工具与中断见 04-tools-and-interrupts.md。这里不重复循环控制。
1. 这是什么(零基础也能懂)
一句话定义: Genkit 把「怎么问模型」(提示模板)和「模型该怎么答」(输出格式)都从 散落的字符串拼接,升级成了两个可复用、可注册、可校验的构件。
它解决什么问题。 假设你在写一个「从简历里抽取结构化信息」的功能。朴素做法有两个痛点:
- 提示这端:你把用户名、岗位拼进一个大字符串,散落在代码里,难复用、难改、难给非工程师维护。
- 输出这端:你在 prompt 末尾手写「请返回 JSON,字段有……」,然后
JSON.parse(resp)—— 可模型经常多包一层```json、少个右括号、或者流式还没吐完,JSON.parse直接抛异常。
Genkit 把这两件事各做成一个子系统:
| 这一端 | 朴素做法 | Genkit 的做法 |
|---|---|---|
| 输入(提示) | 代码里拼字符串 | Dotprompt:.prompt 文件 / definePrompt,编译成可调用的 executable-prompt |
| 输出(结构) | 手写指令 + JSON.parse | format 插件:自动注入格式指令 + 健壮解析(容忍残缺/流式) |
用起来什么样。 一个 .prompt 文件就是「YAML frontmatter(配置)+ Handlebars 正文(模板)」:
上面是仓库里的真实样例 js/testapps/prompt-file/prompts/recipe.prompt。加载后,你像调函数一样用它:
// 示意,非源码
const recipePrompt = ai.prompt('recipe'); // 按名字取出 executable-prompt
const { output } = await recipePrompt({ food: '披萨' }); // 传入 input,直接拿到 Recipe 类型的对象
// output 已经是解析、抠取好的结构化对象,不是一坨字符串
一句话直觉: 把 .prompt 文件想成「带类型签名的模板函数」——入参是 input schema,
出参是 output schema;中间的字符串拼装、格式指令、残缺 JSON 的抠取,框架都替你办了。
2. 顶层全景(它大概怎么转)
整章围绕一次「渲染 → 调用 → 解析」展开。怎么读下面这张图:从左到右是数据流;上半是输入端 (提示→messages),下半是输出端(模型文本→结构化类型)。
输入端(提示模板化) 输出端(结构化)
┌───────────────────────────┐
│ definePrompt / .prompt 文件 │
│ system / prompt / messages│
└────────────┬──────────────┘
│ compile(Handlebars) + 填 input
▼
┌───────────────────────────┐ ┌────────────────────────────┐
│ renderOptionsFn │ │ format 插件 (json/array/…) │
│ 渲染成 MessageData[] │ │ · instructions(格式指令) │
└────────────┬──────────────┘ │ · parseMessage / parseChunk│
│ └──────────────┬─────────────┘
▼ │
┌───────────────────────────┐ applyFormat 注入指令 │
│ generate() ← 模型调用 │◄──────────────────────┘
│ (见第 3 章的工具循环) │
└────────────┬──────────────┘
│ 模型返回文本(可能残缺/带围栏)
▼
┌───────────────────────────┐
│ response.output (getter) │ 调 parser → extractJson/extractItems
│ → 可校验的结构化对象 │ → 你的类型 T
└───────────────────────────┘
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
definePrompt / prompt | 定义/查出一个 executable-prompt | js/ai/src/prompt.ts |
renderOptionsFn | 把模板 + input 渲染成 messages 和 GenerateOptions | js/ai/src/prompt.ts:260 |
Formatter | 一个格式的定义:配置 + handler(给指令、给解析器) | js/ai/src/formats/types.ts:24 |
configureFormats | 把 5 个内建 format 注册进 registry | js/ai/src/formats/index.ts:131 |
applyFormat | 把 format 的指令注入提示、把 config 合并进请求 | js/ai/src/generate/action.ts:186 |
extractJson / extractItems | 从残缺文本里健壮抠 JSON | js/ai/src/extract.ts |
主线走一遍(高层): 你调 recipePrompt(input) → renderOptionsFn 用 Handlebars 编译并填入
input,产出 messages 与带 output 配置的 GenerateOptions → generate() 里 applyFormat
解析出 format、把「格式指令」注入到 system/user 消息 → 模型返回文本 → response.output
这个 getter 调用 format 的 parseMessage,内部用 extractJson 把可能残缺的文本抠成类型 T。
3. 输入端:Dotprompt —— 把提示编译成可调用的 Action
3.1 它要解决的小问题
「提示」不该是散在代码里的字符串。它应该像一个注册在案、能被名字查到、能像函数一样调用的构件—— 这样才能复用、能在 Dev UI 里调试、能当工具给别的 agent 用。这正是 Action 原语的价值 (见 01-action-and-registry.md);Dotprompt 就是把提示装进 Action。
3.2 思路:一个 prompt,背后是两个 Action
definePrompt 定义一个提示时,definePromptAsync 会同时在 registry 里注册两个不同类型的 Action,
它们喂给下游的东西不一样:
| Action 类型 | 输出 | 用途 |
|---|---|---|
prompt(renderer) | GenerateRequest(渲染好的请求) | 只渲染:拿到 messages,不调用模型 |
executable-prompt | GenerateActionOptions | 可执行:一路走到模型调用 |
js/ai/src/prompt.ts:378 注册 prompt 类型的 rendererAction,js/ai/src/prompt.ts:410 注册
executable-prompt 类型。两者的 fn 都先调同一个 renderOptionsFn,区别只在最后一步是
toGenerateRequest 还是 toGenerateActionOptions。
3.3 executable-prompt:像函数一样调用
对外,definePrompt 返回的不是裸 Action,而是一个 ExecutablePrompt 对象——它本身可被调用,
还挂了 .stream() / .render() / .asTool() 几个方法。wrapInExecutablePrompt
(js/ai/src/prompt.ts:462)负责把这几件套上去:
// 示意,非源码:ExecutablePrompt 的四种用法
const p = ai.prompt('recipe');
await p({ food: '披萨' }); // 直接调用 → 渲染 + 调模型,返回 GenerateResponse
p.stream({ food: '披萨' }); // 流式
await p.render({ food: '披萨' }); // 只渲染成 GenerateOptions,不调模型
await p.asTool(); // 把这个 prompt 当成工具暴露
主体那个「可调用函数」在 js/ai/src/prompt.ts:476:它在一个新 span 里调 renderOptionsFn(input, opts)
拿到渲染结果,再交给 generate()——也就是说 prompt 调用最终落到第 3 章的 generate 引擎上,
Dotprompt 只负责「把模板变成 messages」这前半段。.render(:503)则只返回渲染结果、不碰模型,
适合你想先看看提示长什么样、或自己接管调用。
3.4 渲染三段:system / messages / prompt
renderOptionsFn(js/ai/src/prompt.ts:260)的核心是按固定顺序渲染三类消息,顺序有讲究
(system 在前,历史 messages 居中,user prompt 在后):
renderSystemPrompt → renderMessages → renderUserPrompt
(role: system) (历史/多轮) (role: user)
三个函数(:528 / :575 / :629)结构一致,每个都支持三种形态的输入:
- 函数:
system: (input, {state, context}) => ...——动态生成,直接算出 parts。 - 字符串:当成 Handlebars 模板,
registry.dotprompt.compile(...)编译后渲染 (js/ai/src/prompt.ts:552)。编译结果会记进promptCache,同一个 prompt 反复调用不重复编译。 - 已经是 Part[]:直接归一化塞进去。
真正把编译后的模板函数「填入 input、context、session 状态」并渲染成 parts 的,是
renderDotpromptToParts(js/ai/src/prompt.ts:732)。注意它有个约束:一段 parts 模板只能产出
一条消息,否则抛错(prompt.ts:751-753)。
3.5 从磁盘加载 .prompt 文件
.prompt 文件不是凭空出现的。loadPromptFolder(js/ai/src/prompt.ts:768)递归扫描目录:
- 普通
xxx.prompt→loadPrompt(:837)读文件、registry.dotprompt.parse解析出 frontmatter 和模板,再走definePromptAsync注册。文件名里的.会被拆成name.variant(如recipe.robot.prompt→ name=recipe,variant=robot)。 - 以
_开头的文件(如_style.prompt)→ 注册成 partial(definePartial,:821), 即可被别的模板{{>style}}引入的模板片段。
关键设计:loadPrompt 用 lazy(...) 包住 metadata 渲染(prompt.ts:859 附近),延迟到首次使用
才真正解析——注释点明原因:否则加载可能早于用户配置好 schema,导致 renderMetadata 出错。
注意 .prompt 文件的 output.schema 会在加载时被映射进 prompt 的 output 配置
(prompt.ts:899-902,format 和 jsonSchema 都从 frontmatter 来)——这就把输入端和下面的输出端接上了。
4. 输出端:format 体系 —— 让模型吐出可校验的类型
4.1 它要解决的小问题
模型只会吐文本。你想要一个 {name, age} 对象,得干两件事:
- 告诉模型「请按这个 schema 输出 JSON」——这叫格式指令(instructions)。
- 把返回的文本解析成对象— —而且要能容忍模型多嘴、少括号、流式没吐完。
Genkit 把「一种输出格式」抽象成一个 Formatter,一个对象同时提供这两样东西。
4.2 Formatter 的形状
Formatter(js/ai/src/formats/types.ts:24)是个很小的接口——理解了它就理解了整个输出端:
// 摘自 formats/types.ts:24,已简化注释
interface Formatter<O, CO> {
name: string;
config: ModelRequest['output'] & { defaultInstructions?: false };
handler: (schema?) => {
parseMessage(message): O; // 从完整消息解析(非流式)
parseChunk?: (chunk) => CO; // 从流式分片解析(可选)
instructions?: string; // 要注入提示的格式指令(可选)
};
}
config描述这个格式对模型请求意味着什么(contentType、constrained……)。handler(schema)才是干活的:吃一个 JSON schema,吐出「指令 + 两个解析器」。指令是根据 schema 现拼的,解析器则是对应这种格式的抠取逻辑。
4.3 五个内建 format
DEFAULT_FORMATS(js/ai/src/formats/index.ts:120)列了 5 个,configureFormats(:131)在
genkit() 初始化时(js/genkit/src/genkit.ts:654)把它们逐个 defineFormat 注册进 registry:
| format | 目标类型 | 指令要模型做什么 | 解析器怎么抠 | 文件 |
|---|---|---|---|---|
json | 单个对象 | 「输出符合此 schema 的 JSON」 | extractJson(容忍残缺) | formats/json.ts:20 |
array | 对象数组 | 「输出符合此 schema 的 JSON 数组」 | extractItems(逐个完整对象) | formats/array.ts:21 |
jsonl | 对象序列 | 「每行一个 JSON 对象,\n 分隔」 | 按行 JSON5.parse | formats/jsonl.ts:29 |
enum | 枚举字符串 | 「只输出这些枚举值之一,别加引号」 | 去引号 trim | formats/enum.ts:20 |
text | 纯文本 | (无指令) | 原样返回文本 | formats/text.ts:19 |
注意 json 的 config.defaultInstructions: false(formats/json.ts:26)——这个标志决定它默认
不主动注入指令(留给下面的 §4.5 讲),因为很多模型对 JSON 有原生的 constrained 输出支持。
4.4 指令是「按 schema 现拼」的
以 json 为例,handler 收到 schema 后拼出的指令(formats/json.ts:31-38)长这样:
Output should be in JSON format and conform to the following schema:
```
{"type":"object","properties":{"name":{"type":"string"}}}
```
enum(formats/enum.ts:35-36)则完全不同——它把 schema.enum 的每个值列出来,并强调
「Do not output any additional information or add quotes」。每种格式的指令都是为「让模型更可能吐对」而定制的。
array 和 jsonl 还会在 handler 一开始校验 schema 类型:array 要求 schema.type === 'array'
(formats/array.ts:28),jsonl 要求是 array-of-object(formats/jsonl.ts:35-43),否则直接抛
INVALID_ARGUMENT。这是「结构化」的第一道闸:schema 和 format 对不上,提前失败。
4.5 指令怎么被注入提示(三个纯函数)
format 产出指令后,是谁、在哪、按什么规则把它塞进消息?答案在 formats/index.ts 的三个纯函数,
由 applyFormat(js/ai/src/generate/action.ts:186)在每次 generate 时串起来:
① 该不该注入? shouldInjectFormatInstructions(action.ts:222):
// 摘自 generate/action.ts:222
return (
formatConfig?.defaultInstructions !== false || // format 没说「别默认注入」
rawRequestConfig?.instructions // 或用户显式要了指令
);
这解释了 §4.3 那个 json.defaultInstructions:false:json 格式默认不注入文字指令(依赖模型的
constrained 能力),但用户可以用 output.instructions: true 强行要回来。
② 用哪段指令? resolveInstructions(formats/index.ts:62)按优先级选:用户传的字符串 >
用户传 false(不要) > format 按 schema 现拼的。
③ 塞哪里? injectInstructions(formats/index.ts:73)把指令做成一个带
metadata.purpose:'output' 的 TextPart,优先塞进 system 消息,没有就塞最后一条 user 消息
(index.ts:96-98)。它还有两个防重复的巧思:
- 若已存在一个非 pending 的 output part,直接 bail(说明指令已注入过),
index.ts:80-88。 - 若存在一个 pending 占位的 output part,就地替换它,而不是追加(
index.ts:106-113)—— 这让模板作者能用{{role "..."}}之类手段预留位置,控制指令出现的位置。
5. 健壮解析:从残缺文本里抠出 JSON
这是整个输出端工程含量最高的一支——难点不是「解析 JSON」,而是「模型给的文本几乎从不是干净的 JSON」:
外面裹着散文、包着 ```json 围栏、流式时后半截还没到、偶尔少个右括号。js/ai/src/extract.ts
就是专门对付这些的。
5.1 extractJson:定位 + 容忍残缺
extractJson(js/ai/src/extract.ts:30)不用正则,而是手写一个字符扫描器:
- 逐字符扫,正确处理字符串内的引号与转义(
inString/escapeNext),避免把字符串里的{}误当结构。 - 遇到第一个
{或[记为开头,用nestingCount配对括号;配平那一刻,就JSON5.parse截出的那段返回(extract.ts:83-86)。它只要第一个完整的 JSON,后面的散文一概不管。 - 如果扫到末尾还没配平(
nestingCount > 0,典型的流式未完成),就退而求其次调parsePartialJson(extract.ts:90-94)。
parsePartialJson(extract.ts:23)用 partial-json 库以 Allow.ALL 宽容解析残缺结构,
再过一遍 JSON5。这就是流式下 chunk.output 能一路吐出「越来越完整的对象」的原因。
用 JSON5 而非原生 JSON.parse 也是有意的:能容忍单引号、尾逗号、无引号 key 等模型常犯的小毛病。
5.2 extractItems:流式数组的增量抠取
数组格式要的是「每来一个完整对象就吐一个」,不能等整个数组闭合。extractItems
(extract.ts:119)为此设计:它从一个 cursor 位置开始扫,只收集已经闭合的顶层对象,
返回收集到的 items 和新的 cursor。
流式的 arrayFormatter.parseChunk(formats/array.ts:46-54)据此做增量:先用上一批文本算出 cursor,
再从 cursor 处抠新对象——这样每个对象只被解析一次,不重复。
流式文本逐渐到达: [ {"a":1}, {"a":2}, {"a":3
└──┬──┘ └──┬──┘ └── 还没闭合,不吐
cursor 前已吐过 本次吐出 下次再说
5.3 output getter:解析发生在「读取」时
解析器不是主动跑的,而是挂在响应对象的 output getter 上,按需触发:
GenerateResponse.output(js/ai/src/generate/response.ts:147)→ 转发到 message。Message.output(js/ai/src/message.ts:84-85):this.parser?.(this) || this.data || extractJson(this.text)——有 format 解析器就用它,否则退回 data part,再退回裸extractJson。GenerateResponseChunk.output(js/ai/src/generate/chunk.ts:139-141):流式同理,用parseChunk。
这个 parser 从哪来?generate 流程里,generateActionImpl 解析出 format 后,把
format.handler(schema).parseChunk(action.ts:257)与 .parseMessage(action.ts:488 / :494)
分别塞进 chunk 和 response 的构造参数。于是「哪种 format」在请求阶段就决定了「output 怎么被解析」。
6. 两端合流:一次完整的结构化调用
把前面串起来,一次「带 schema 的 prompt 调用」的数据流:
1. ai.prompt('recipe') → 从 registry 取出 executable-prompt
2. p({food:'披萨'}) → renderOptionsFn:
· Handlebars 编译 + 填 input → messages
· frontmatter 的 output.schema → GenerateOptions.output = {format:'json', jsonSchema:Recipe}
3. generate(opts) → applyFormat:
· resolveFormat → jsonFormatter
· shouldInjectFormatInstructions? → 按规则决定
· injectInstructions → 把「符合此 schema 的 JSON」塞进 system 消息
· 把 format.config 合并进请求(contentType/constrained)
4. 模型返回文本(可能带围栏/残缺)
5. 读 response.output → parseMessage → extractJson → Recipe 对象
关键点: 输入端(Dotprompt)只负责产出 messages 和一份 output 配置;真正把「格式指令」
织进消息、把「解析器」挂上响应的,是 generate 里的 applyFormat。所以 §4/§5 的机制对
ai.generate(...) 直接调用同样生效,不是 prompt 专属——prompt 只是帮你把 output 配置从
.prompt 文件里带过来而已。
7. 巧妙之处(可借鉴的技术)
- 一个提示,两个 Action。 同一份定义按需产出
prompt(只渲染)和executable-prompt(可 执行) 两种 Action(prompt.ts:378/:410),让「我只想看渲染结果」和「我要真跑」共享同一套渲染逻辑。 - format = 指令 + 解析器的打包。 把「怎么让模型吐对」和「怎么把结果读回来」放进同一个对象
(
formats/types.ts:24),两者天然同步——改了指令就该改解析器,它们在一处。 - defaultInstructions 的开关语义。
json默认不注入文字指令(json.ts:26+action.ts:222), 把「靠模型 constrained 能力」当默认、「靠文字指令」当可选,一个布尔标志表达了这层策略。 - pending 占位符替换。
injectInstructions能就地替换模板预留的 pending output part (index.ts:106-113),让模板作者能控制格式指令出现的位置,而不是永远被追加到末尾。 - 手写字符扫描器而非正则。
extractJson用状态机处理字符串/转义/嵌套(extract.ts:49-88), 能在一坨散文里精准截出第一个完整 JSON,并在残缺时优雅降级到 partial-json。 - cursor 增量解析。
extractItems用游标只解析新对象(extract.ts:119),让流式数组 「每个元素只解析一次」,是流式结构化输出低开销的关键。 - 懒加载 .prompt。
loadPrompt用lazy延迟 metadata 渲染到首次使用(prompt.ts:859), 避开「加载早于 schema 配置」的初始化顺序陷阱。
8. 边界与局限(诚实)
- 指令不等于保证。 注入的格式指令只是「请求」模型合作。真正的硬约束靠模型端的
constrained能力(config.constrained,formats/json.ts:26等);不支持的模型仍可能吐歪, 这时全靠 §5 的容错解析兜底。 - 一段 parts 模板只能产一条消息。
renderDotpromptToParts对多消息模板直接抛错 (prompt.ts:751-753)。 - enum 解析很朴素。 只是去引号 + trim(
formats/enum.ts:41),不校验结果是否真在枚举集合内—— 模型若吐了集合外的词,不会在这里被拦。 - jsonl 的流式解析遇错即停。
parseChunk中一行解析失败就break(formats/jsonl.ts:79-81), 假定坏行意味着「后面还没吐完」,而非跳过继续。 - extractJson 只认第一个 JSON。 文本里若有多个顶层 JSON,只返回第一个完整的
(
extract.ts:83-86);要多个得用 array/jsonl 格式。 - Dotprompt 引擎本身在外部依赖。 Handlebars 编译、frontmatter 解析都由
dotprompt包 (registry.dotprompt)提供,本仓库只做「接线」;模板语法细节不在本章代码范围内。
9. 横向对比
- 与 03-generate-and-tool-loop.md:generate 是引擎,本章是它的
输入前处理(提示渲染)和输出后处理(格式解析)。
applyFormat是两章的接缝。 - 与 01-action-and-registry.md:prompt 和 format 都注册进同一个
registry——prompt 是 Action,format 是
registerValue('format', ...)的值;「一切皆可注册」在这里再次体现。 - 与 04-tools-and-interrupts.md:工具用 schema 校验输入, format 用 schema 约束输出;两者都把 Zod/JSON schema 当作模型与代码之间的类型契约。
10. 代码地图(导航索引)
| 主题 | 文件 | 关键符号 |
|---|---|---|
| 定义提示(同时注册两个 Action) | js/ai/src/prompt.ts | definePrompt、definePromptAsync |
| 渲染提示为 messages/options | js/ai/src/prompt.ts | renderOptionsFn、renderSystemPrompt、renderMessages、renderUserPrompt |
| ExecutablePrompt 的可调用包装 | js/ai/src/prompt.ts | wrapInExecutablePrompt、ExecutablePrompt |
| 模板渲染成 parts | js/ai/src/prompt.ts | renderDotpromptToParts |
| 加载 .prompt 文件/partial | js/ai/src/prompt.ts | loadPromptFolder、loadPrompt、definePartial |
| 按名字查出提示 | js/ai/src/prompt.ts | prompt、lookupPrompt |
| Formatter 接口 | js/ai/src/formats/types.ts | Formatter |
| 注册内建格式 | js/ai/src/formats/index.ts | configureFormats、DEFAULT_FORMATS、defineFormat |
| 解析/注入格式指令 | js/ai/src/formats/index.ts | resolveFormat、resolveInstructions、injectInstructions |
| json / array 格式 | js/ai/src/formats/json.ts、array.ts | jsonFormatter、arrayFormatter |
| jsonl / enum / text 格式 | js/ai/src/formats/jsonl.ts、enum.ts、text.ts | jsonlFormatter、enumFormatter、textFormatter |
| 是否注入格式指令 | js/ai/src/generate/action.ts | shouldInjectFormatInstructions、applyFormat |
| 健壮 JSON 抽取 | js/ai/src/extract.ts | extractJson、parsePartialJson、extractItems |
| output 解析的挂载点 | js/ai/src/message.ts、generate/chunk.ts、generate/response.ts | output(getter)、parser |