告别"求"模型输出 JSON
在使用 Google ADK(Agent Development Kit)重构 Go 代码的过程中,我意识到一个根本性的问题:我们长期以来在 Prompt 中反复强调输出格式,实际上是一种低效的做法。本文将探讨从"提示词工程"到"类型工程"的范式转变。
引言:格式控制的困境
从事大模型应用开发的工程师,大多经历过这样的困扰:为了让 LLM 稳定输出程序可解析的结构化数据,我们不得不在 Prompt 中投入大量篇幅来约束输出格式。
“Please output valid JSON.” “Do not include markdown backticks.” “Ensure all keys are strings.”
然而效果并不理想。模型时常因为多余的逗号,或是在输出开头添加一句"Here is the JSON you requested",导致 json.Unmarshal 解析失败。我们被迫编写各种正则表达式来清洗数据,这显然不是可持续的解决方案。
近期,在使用 Google ADK 和 Go 语言重构单词提取 Agent 时,我删除了 Prompt 中所有关于格式的指令,程序的稳定性反而显著提升。关键在于:Function Calling 与强类型系统的结合。
核心机制:ADK 的三层抽象
为什么不显式规定格式,模型却能正确输出?因为在 ADK 的设计中,代码结构体本身承担了格式约束的职责。
第一层:代码即约束(Code as Constraint)
传统开发中,我们将格式要求写在 Prompt 字符串里。而在 ADK 中,格式约束通过 Go 结构体来表达:
type SaveCardsArgs struct {
WordCards []WordCard `json:"word_cards" jsonschema:"提取到的单词列表"`
CollocationCards []CollocationCard `json:"collocation_cards" jsonschema:"提取到的词组列表"`
}jsonschema 标签是关键。程序启动时,ADK 通过 Go 的反射机制扫描结构体,自动生成标准的 JSON Schema。
这份 Schema 本质上是一份形式化的、机器可验证的约束规范。相比自然语言描述,它具有无歧义性。
第二层:工具协议(Tool Protocol)
将结构体包装为 Tool 并传递给 Gemini 模型时,交互模式发生了根本性变化。
- 传统方式(Prompt Engineering):相当于给模型一张白纸,用自然语言描述期望的格式。模型的输出具有较高的不确定性。
- 工具调用方式(Tool Use):ADK 向模型提供了一份预定义的数据结构,模型的任务是按规范填充数据。
Gemini 接收的请求中包含独立的 tools 定义。模型若要完成工具调用,必须严格遵循 Schema 规范生成数据,否则调用将无法成功。
第三层:确定性执行(Deterministic Execution)
这一点对后端工程师尤为重要。
Gemini 处理完成后,返回的不是包含 JSON 的文本,而是结构化的函数调用请求(Function Call)。ADK 拦截该请求并自动完成反序列化。
进入业务函数 SaveCardsToolImpl 的,是类型安全的 Go 结构体实例,而非需要清洗的字符串。
这是一种控制反转:不再是我们解析模型的输出,而是模型主动适配我们定义的数据结构。
实践:构建 Anki 卡片提取 Agent
以下是基于 Google ADK 的实际代码示例。该 Agent 分析英文文章,提取单词和词组,并生成 CSV 文件。
1. 定义数据结构
数据结构是整个系统的核心契约。通过 Tags 向模型传达字段语义。
// 对应 CSV 的一行数据
type WordCard struct {
Word string `json:"word" csv:"Word"`
Definition string `json:"definition" csv:"Definition"`
Sentence string `json:"sentence" csv:"Sentence"`
// ... 其他字段
}
// Agent 工具的输入参数
type SaveCardsArgs struct {
WordCards []WordCard `json:"word_cards" jsonschema:"提取到的单词卡片列表"`
CollocationCards []CollocationCard `json:"collocation_cards" jsonschema:"提取到的词组搭配卡片列表"`
}2. 实现工具函数
工具函数是 Agent 执行具体操作的入口。入参直接使用上述结构体,无需进行 JSON 解析。
func SaveCardsToolImpl(ctx tool.Context, args SaveCardsArgs) (string, error) {
// args.WordCards 已是填充完毕的 Go Slice
// 业务逻辑:写入 CSV
timestamp := time.Now().Format("20060102_150405")
filename := fmt.Sprintf("anki_words_%s.csv", timestamp)
// 调用 CSV 写入逻辑(细节略)
if err := writeCSV(filename, args.WordCards); err != nil {
return "", err
}
return fmt.Sprintf("成功保存了 %d 个单词", len(args.WordCards)), nil
}3. 组装 Agent
在 main 函数中,将函数包装为 Tool 并注册给 Agent。
func main() {
ctx := context.Background()
// 初始化 Gemini 模型
model, _ := gemini.NewModel(ctx, "gemini-2.0-flash-lite", &genai.ClientConfig{
APIKey: os.Getenv("GOOGLE_API_KEY"),
})
// 将 Go 函数转换为 AI 工具
// ADK 自动分析 SaveCardsToolImpl 的入参结构,生成 JSON Schema
saveTool, _ := functiontool.New(
functiontool.Config{
Name: "save_anki_cards",
Description: "提取任务完成后,调用此工具保存数据。",
},
SaveCardsToolImpl,
)
// 创建 Agent
extractorAgent, _ := llmagent.New(llmagent.Config{
Name: "vocabulary_extractor",
Model: model,
Tools: []tool.Tool{saveTool},
// Prompt 专注于业务逻辑,不涉及格式约束
Instruction: `你是一个语言助手。分析用户文本,提取:
1. 陌生单词 (WordCard)
2. 地道搭配 (CollocationCard)
提取后调用 save_anki_cards 工具保存。`,
})
// 启动交互
l := full.NewLauncher()
l.Execute(ctx, &launcher.Config{AgentLoader: agent.NewSingleLoader(extractorAgent)}, os.Args[1:])
}结语
AI 应用开发正在经历从经验驱动向工程化的演进。
- Prompt Engineering 依赖自然语言来引导模型输出,本质上是概率性的。
- Type Engineering(姑且这样称呼)则利用编程语言的类型系统,为模型输出提供形式化约束。
Google ADK 展示了 Go 语言强类型特性与 AI 结合的可能性。开发者的角色从 Prompt 编写者转变为接口设计者:定义数据结构,由模型负责填充。
如果你仍在为 JSON 输出的稳定性而困扰,不妨尝试 ADK 的思路:与其约束格式,不如定义结构。