跳到主要内容

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.parseformat 插件:自动注入格式指令 + 健壮解析(容忍残缺/流式)

用起来什么样。 一个 .prompt 文件就是「YAML frontmatter(配置)+ Handlebars 正文(模板)」:

---
model: googleai/gemini-pro-latest
input:
schema:
food: string
ingredients?(array): string
output:
schema: Recipe # 指定输出要符合 Recipe 这个 schema
---
You are a chef famous for making creative recipes.
Generate a recipe for {{food}}.
{{#if ingredients}}
Make sure to include: {{list ingredients}}
{{/if}}

上面是仓库里的真实样例 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-promptjs/ai/src/prompt.ts
renderOptionsFn把模板 + input 渲染成 messagesGenerateOptionsjs/ai/src/prompt.ts:260
Formatter一个格式的定义:配置 + handler(给指令、给解析器)js/ai/src/formats/types.ts:24
configureFormats把 5 个内建 format 注册进 registryjs/ai/src/formats/index.ts:131
applyFormat把 format 的指令注入提示、把 config 合并进请求js/ai/src/generate/action.ts:186
extractJson / extractItems从残缺文本里健壮抠 JSONjs/ai/src/extract.ts

主线走一遍(高层): 你调 recipePrompt(input)renderOptionsFn 用 Handlebars 编译并填入 input,产出 messages 与带 output 配置的 GenerateOptionsgenerate()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-promptGenerateActionOptions可执行:一路走到模型调用

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.promptloadPrompt(:837)读文件、registry.dotprompt.parse 解析出 frontmatter 和模板,再走 definePromptAsync 注册。文件名里的 . 会被拆成 name.variant(如 recipe.robot.prompt → name=recipe,variant=robot)。
  • _ 开头的文件(如 _style.prompt)→ 注册成 partial(definePartial,:821), 即可被别的模板 {{>style}} 引入的模板片段。

关键设计:loadPromptlazy(...) 包住 metadata 渲染(prompt.ts:859 附近),延迟到首次使用 才真正解析——注释点明原因:否则加载可能早于用户配置好 schema,导致 renderMetadata 出错。

注意 .prompt 文件的 output.schema 会在加载时被映射进 prompt 的 output 配置 (prompt.ts:899-902,formatjsonSchema 都从 frontmatter 来)——这就把输入端和下面的输出端接上了


4. 输出端:format 体系 —— 让模型吐出可校验的类型

4.1 它要解决的小问题

模型只会吐文本。你想要一个 {name, age} 对象,得干两件事:

  1. 告诉模型「请按这个 schema 输出 JSON」——这叫格式指令(instructions)。
  2. 把返回的文本解析成对象——而且要能容忍模型多嘴、少括号、流式没吐完。

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 描述这个格式对模型请求意味着什么(contentTypeconstrained……)。
  • 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.parseformats/jsonl.ts:29
enum枚举字符串「只输出这些枚举值之一,别加引号」去引号 trimformats/enum.ts:20
text纯文本(无指令)原样返回文本formats/text.ts:19

注意 jsonconfig.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」。每种格式的指令都是为「让模型更可能吐对」而定制的。

arrayjsonl 还会在 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。 loadPromptlazy 延迟 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.tsdefinePromptdefinePromptAsync
渲染提示为 messages/optionsjs/ai/src/prompt.tsrenderOptionsFnrenderSystemPromptrenderMessagesrenderUserPrompt
ExecutablePrompt 的可调用包装js/ai/src/prompt.tswrapInExecutablePromptExecutablePrompt
模板渲染成 partsjs/ai/src/prompt.tsrenderDotpromptToParts
加载 .prompt 文件/partialjs/ai/src/prompt.tsloadPromptFolderloadPromptdefinePartial
按名字查出提示js/ai/src/prompt.tspromptlookupPrompt
Formatter 接口js/ai/src/formats/types.tsFormatter
注册内建格式js/ai/src/formats/index.tsconfigureFormatsDEFAULT_FORMATSdefineFormat
解析/注入格式指令js/ai/src/formats/index.tsresolveFormatresolveInstructionsinjectInstructions
json / array 格式js/ai/src/formats/json.tsarray.tsjsonFormatterarrayFormatter
jsonl / enum / text 格式js/ai/src/formats/jsonl.tsenum.tstext.tsjsonlFormatterenumFormattertextFormatter
是否注入格式指令js/ai/src/generate/action.tsshouldInjectFormatInstructionsapplyFormat
健壮 JSON 抽取js/ai/src/extract.tsextractJsonparsePartialJsonextractItems
output 解析的挂载点js/ai/src/message.tsgenerate/chunk.tsgenerate/response.tsoutput(getter)、parser