告别"求"模型输出 JSON

告别"求"模型输出 JSON

December 2, 2025

在使用 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 的思路:与其约束格式,不如定义结构。