Koog(7)——模型调用的输入域输出:多模态、参数、流式与结构化

前言

上一篇文章解决了"上下文从哪来、任务从哪继续"。但一次模型调用本身还有两个问题没有讲完:输入只能是一段文字吗?输出只能拿到完整结果吗?

这篇把这两个问题补齐,同时兑现前面留下的三个承诺。第三篇讲提示词参数时说过"详细内容将在后面的文章讲解";第三篇讲流式响应时说过"流式 API 后面的文章单独讲解";第六篇 1.2.4 也预告了"结构化、流式请求有各自的处理方式"。

所以本文的顺序就是一次调用的完整过程:

  • 输入:user 消息里可以带图片、音频、视频和文件(第一章)。
  • 控制:LLMParams 的八个字段、三种使用位置(第二章)。
  • 输出:流式拿增量(第三章),或直接拿结构化对象(第四章)。
  • 组合:怎么选、两种缓存怎么区分(第五章)。

本文继续以 Koog 1.3.0 的 Kotlin API 为基准;DeepSeek 客户端与模型定义还使用 koog-agents-additions:1.3.0-beta,这部分仍是预览依赖。文中的完整程序采用 Kotlin 2.4.10、JDK 21 编译运行,第三章的流式与第四章的结构化、修复请求均直接调用真实 DeepSeek。模型生成内容每次可能不同;消息构造、schema 生成、参数校验和缓存配置等本地操作则不额外请求模型。局部片段会明确标注用途;官方页面与发布版本有出入时,以 1.3.0 源码为准。

一、多模态消息:user 消息里不只有文本

1.1 四类附件

user 消息的大多数内容是纯文本,但 Koog 允许在 user 消息中把多媒体内容与文本一起发给模型。四类附件函数与常见格式:

函数 用途 常见格式
image() 附加图像 JPG、PNG、WebP、GIF
audio() 附加音频 MP3、WAV、FLAC
video() 附加视频 MP4、AVI、MOV
file() / binaryFile() / textFile() 附加文档 PDF、TXT、MD 等,需提供 MIME 类型

注意:多模态支持因 provider 而异,且与具体模型的能力声明绑定 。Koog 侧能把消息构造出来,不代表你选的模型真的能读它------校验发生在发起请求前。实测 DeepSeek V4 Flash 不支持 document 附件,带上 file() 附件的真实调用会直接抛出:

text 复制代码
IllegalArgumentException: Model deepseek-v4-flash does not support document

接入前要确认所用模型声明了对应附件类型的能力。

1.2 自动配置的附件

最省事的用法是把 URL 或文件路径直接传给附件函数,Koog 按扩展名推断格式和 MIME 类型:

kotlin 复制代码
// 片段:写在 prompt 构建的 user { } 块中。
user {
    +"描述这些图片:"
    image("https://example.com/test.png")
    +"关注主体。"
}

+ 运算符把文本与附件按书写顺序混排在同一条 user 消息里,顺序会保留到最终发给模型的消息中。

一个 1.3.0 特有的细节:image(path) 的参数是 kotlinx.io.files.Path,不是 java.nio.file.Path 。从字符串构造用 kotlinx.io.files.Path("/path/to/image.png");从已有的 java.nio.file.Path 转换用 kotlinx.io.files.Path(nioPath.toString())。直接传 java 类型编译不过。

还有一个行为值得知道:本地路径的附件在构建消息时就读入内存 。下面的离线实验可以看到,路径附件变成了 Bytes 内容。

1.3 实际构造一条消息看看

不调模型也能验证消息结构------构建 prompt 本身不需要 executor。保存为 src/main/kotlin/MultimodalDemo.kt(package 为 multimodaldemo),执行 ./gradlew run -PmainClass=multimodaldemo.MultimodalDemoKt:

kotlin 复制代码
package multimodaldemo

import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.message.AttachmentContent
import ai.koog.prompt.message.AttachmentSource
import ai.koog.prompt.message.Message
import ai.koog.prompt.message.MessagePart
import java.nio.file.Files

fun describe(parts: List<MessagePart>): String = parts.joinToString(" | ") { part ->
    when (part) {
        is MessagePart.Text -> "Text(${part.text.take(12)}...)"
        is MessagePart.Attachment -> {
            val s = part.source
            "Attachment(${s::class.simpleName}, format=${s.format}, mime=${s.mimeType}, content=${s.content::class.simpleName})"
        }
        else -> part::class.simpleName ?: "?"
    }
}

fun main(args: Array<String>) {
    // 自动配置:URL 与本地路径,按扩展名推断 format 与 mimeType。
    val tmp = Files.createTempFile("demo", ".png")
    // 仅用四个字节验证消息构造,不是可交给视觉模型识别的完整 PNG。
    Files.write(tmp, byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47))

    val autoPrompt = prompt("auto") {
        system("你是图像分析助手。")
        user {
            +"描述这些图片:"
            image("https://example.com/test.png")
            image(kotlinx.io.files.Path(tmp.toString()))
            +"关注主体。"
        }
    }
    val userMsg = autoPrompt.messages.filterIsInstance<Message.User>().first()
    println("AUTO parts=${userMsg.parts.size}")
    println(describe(userMsg.parts))

    // 自定义配置:显式构造 AttachmentSource.Image。
    val customPrompt = prompt("custom") {
        user {
            +"描述这张图"
            image(
                AttachmentSource.Image(
                    content = AttachmentContent.URL("https://example.com/capture.png"),
                    format = "png",
                    fileName = "capture.png"
                )
            )
        }
    }
    println(describe(customPrompt.messages.filterIsInstance<Message.User>().first().parts))

    // 混合附件:纯文本文件 + 图片(URL 来源)。
    val mixedPrompt = prompt("mixed") {
        system("You are a helpful assistant.")
        user {
            +"Compare the image with the document content."
            file(
                AttachmentSource.File(
                    content = AttachmentContent.PlainText("这是一份纯文本文档的内容。"),
                    format = "txt",
                    mimeType = "text/plain",
                    fileName = "notes.txt"
                )
            )
            image(AttachmentSource.Image(content = AttachmentContent.URL("https://example.com/mix.png"), format = "png"))
            +"Structure the result as a table"
        }
    }
    println(describe(mixedPrompt.messages.filterIsInstance<Message.User>().first().parts))
    Files.deleteIfExists(tmp)
}

实际输出:

text 复制代码
AUTO parts=4
Text(描述这些图片:...) | Attachment(Image, format=png, mime=image/png, content=URL) | Attachment(Image, format=png, mime=image/png, content=Bytes) | Text(关注主体。...)
Text(描述这张图...) | Attachment(Image, format=png, mime=image/png, content=URL)
Text(Compare the ...) | Attachment(File, format=txt, mime=text/plain, content=PlainText) | Attachment(Image, format=png, mime=image/png, content=URL) | Text(Structure th...)

AUTO parts=4 后的三行分别对应自动配置、自定义配置和混合附件。看第一个:四个部分按书写顺序排列,文本、URL 附件、本地文件附件、文本;本地文件的 content=Bytes 证实了 1.2 说的"构建时读入内存"。

1.4 自定义配置与来源类型

需要显式控制附件参数时,直接构造 AttachmentSource 的实现传给对应函数。下面列出四种内容来源,其中二进制来源分为原始字节与 Base64 两种形式:

kotlin 复制代码
// 片段:四种内容来源(其中 PlainText 仅用于文件附件)。
AttachmentContent.URL("https://example.com/image.png")
AttachmentContent.Binary.Bytes(byteArrayOf(/* ... */))
AttachmentContent.Binary.Base64("iVBORw0KGgoAAAANS...")
AttachmentContent.PlainText("This is the file content.")

AttachmentSource.Image / Audio / Video / File 都接受四个参数:

参数 类型 必填 说明
content AttachmentContent 是 内容来源,见上
format String 是 文件格式,通常是扩展名,如 png
mimeType String 仅 File 必填 其余类型默认为 <type>/<format>,如 image/png
fileName String? 否 文件名,含扩展名

在 1.3.0 中,Image、Audio、Video 都不能使用 PlainText 内容,构造时会直接抛异常;File 则要求显式提供 mimeType。

1.5 混合附件

单个 user 消息里可以混排任意多个、任意类型的附件,1.3 的第三个例子就是图片加文本文档,再要求模型以表格形式输出比较结果。这里不再贴第二遍。

一个要提醒的坑:官方文档的这一页写的是 ContentPart.Image 这组类名,与本文核对的发布版本不一致。1.3.0 里对应的是 AttachmentSource.Image,消息里表现为 MessagePart.Attachment。将这组类名直接用于 1.3.0 的工程会编译不过,本文代码都以 1.3.0 实测为准。

二、LLM 参数:控制模型行为的八个字段

2.1 三个使用位置

LLMParams 在三个层级生效:

kotlin 复制代码
import ai.koog.agents.ext.agent.subgraphWithTask

// 以下是三个不同作用域中的局部片段,不是一份完整程序。
// 位置一:创建 prompt 时。
val prompt = prompt(
    id = "dev-assistant",
    params = LLMParams(temperature = 0.7, maxTokens = 500)
) {
    system("You are a helpful assistant.")
    user("Tell me about Kotlin")
}

// 位置二:创建子图时(子图体系见第五篇)。

val processQuery by subgraphWithTask<String, String>(
    tools = listOf(searchTool, calculatorTool, weatherTool),
    llmModel = OpenAIModels.Chat.GPT4o,
    llmParams = LLMParams(temperature = 0.7, maxTokens = 500),
    parallelTools = false
) { userQuery -> "Please help with: $userQuery" }

// 位置三:写会话中动态修改。
llm.writeSession {
    changeLLMParams(LLMParams(temperature = 0.7, maxTokens = 500))
}

位置二来自官方示例,searchTool 等是已注册的工具,片段只展示参数位置。位置三值得和第六篇 1.2 的提交语义连起来看:params 是 Prompt 对象的一部分,所以 changeLLMParams 的修改同样遵循"lambda 正常结束才提交回上下文"的规则。离线实验证实了这一点------写会话里改成 temperature=0.9, maxTokens=777,会话结束后在外层上下文读到:

text 复制代码
after scope: temperature=0.9, maxTokens=777

也就是说,参数修改和消息修改走同一条提交路径,异常传播同样会导致修改丢失。

2.2 参数表与校验

LLMParams 共八个字段,这张表逐字段对照过 1.3.0 源码:

参数 类型 描述
temperature Double? 控制输出随机性,高值更多样、低值更确定
maxTokens Int? 响应的最大 token 数
numberOfChoices Int? 生成的备选响应数量,必须大于 0
speculation String? 推测性配置,仅部分模型支持
schema Schema? 响应的 JSON 结构定义,见 2.4
toolChoice ToolChoice? 控制工具调用行为,见 2.3
user String? 发起请求的用户标识,用于跟踪
additionalProperties Map<String, JsonElement>? provider 特有的自定义参数,见 2.6

八个字段默认均为 null,表示不显式设置该参数;字段是否受支持,还要看 provider 与模型。构造期校验涉及 temperature、numberOfChoices、speculation、user 和 toolChoice,其中最后一项检查 Named 的名称;Named 自身构造时也会校验名称。temperature 超范围的实测输出:

text 复制代码
temperature=2.5 -> IllegalArgumentException: Temperature must be between 0.0 and 2.0, but was 2.5

其余规则:numberOfChoices 必须大于 0;speculation 和 user 不能是空白字符串;ToolChoice.Named 的工具名不能为空。这些是本地构造期校验,不必等到模型请求时才暴露;它们不替代 provider 对参数范围、组合和模型能力的进一步检查。基础 LLMParams 并没有在构造期校验 maxTokens 的范围。

2.3 ToolChoice:控制模型用不用工具

五个选项:

  • Auto:模型自行决定是否调用工具、调用哪个。
  • Named(name):强制调用指定工具。
  • All:调用所有工具。(在 1.3.0 版本中不存在,官方文档有描述)
  • None:不调用工具,仅生成文本。
  • Required:至少调用一个工具。
kotlin 复制代码
val specificToolParams = LLMParams(
    toolChoice = LLMParams.ToolChoice.Named(name = "calculator")
)

和第六篇 1.2.4 串起来:requestLLMWithoutTools() 不只是把本次请求的 toolChoice 置为 null,还会在准备 prompt 和执行请求时传入空工具列表。它不清空 Agent 注册的工具,也不永久改写会话参数。

三种写法不能混为一谈:toolChoice = null 表示不显式指定工具选择策略,并不等于禁用工具;ToolChoice.None 是发送"不调用工具"的选择约束;requestLLMWithoutTools() 则让本次请求不提供工具,并清除相关选择约束。临时不需要工具时,用便捷方法更直接。

2.4 Schema:给响应定结构

LLMParams.schema 字段接受一个 JSON schema,让模型按结构输出。1.3.0 支持两种:

  • LLMParams.Schema.JSON.Basic:基础 JSON schema 表示,用于声明支持该能力的模型。
  • LLMParams.Schema.JSON.Standard:标准 JSON Schema 表示,但具体可用的关键字与约束仍取决于 provider 和模型。

不能简单理解成"OpenAI 只接受 Basic":1.3.0 也提供 OpenAIStandardJsonSchemaGenerator。选择哪一种,要结合模型能力声明和 provider 的 schema 方言。

第四章的结构化输出会看到:手动构造 schema 就是用相应的 Schema.JSON 类型包装 JsonObject,再赋给 LLMParams.schema;而 requestLLMStructured 会从 data class 自动生成这些 schema,通常不需要手写。所以这一节只需要记住两条路径的存在和区别,细节留给第四章。

2.5 Provider 特定参数

基础 LLMParams 之外,各 provider 有自己的参数类,继承并扩展基础字段:

  • OpenAIChatParams / OpenAIResponsesParams(OpenAI 两种 API)
  • GoogleParams、AnthropicParams、MistralAIParams、DeepSeekParams、OpenRouterParams、DashscopeParams、OllamaParams

推理控制是最常用的场景。使用 OpenAI Chat API 且模型支持推理时,用 reasoningEffort 控制推理 token 量:

kotlin 复制代码
import ai.koog.prompt.executor.clients.openai.OpenAIChatParams
import ai.koog.prompt.executor.clients.openai.base.models.ReasoningEffort

val openAIReasoningEffortParams = OpenAIChatParams(
    reasoningEffort = ReasoningEffort.MEDIUM
)

无状态下使用 OpenAI Responses API 时,可以通过 include 请求加密的推理内容,供后续对话轮次传回:

kotlin 复制代码
import ai.koog.prompt.executor.clients.openai.OpenAIResponsesParams
import ai.koog.prompt.executor.clients.openai.models.OpenAIInclude

val openAIStatelessReasoningParams = OpenAIResponsesParams(
    include = listOf(OpenAIInclude.REASONING_ENCRYPTED_CONTENT)
)

5.2 的提示词缓存也是 provider 参数------AnthropicParams 的缓存开关,到那一节再看。

2.6 additionalProperties:兜底的自定义参数

Koog 开箱不支持的 provider 特有参数,用 additionalProperties 透传:

kotlin 复制代码
val customParams = LLMParams(
    additionalProperties = additionalPropertiesOf(
        "top_p" to 0.95,
        "frequency_penalty" to 0.5,
        "presence_penalty" to 0.5
    )
)

2.7 .default():参数组的部分覆盖

定义一组通用参数,再按场景覆盖其中几项,不用每次重复公共值:

kotlin 复制代码
// 通用参数组。
val base = LLMParams(temperature = 0.7, maxTokens = 150, toolChoice = LLMParams.ToolChoice.Auto)

// 覆盖组:只写要改的字段,空位自动用 base 补齐。
val overridden = LLMParams(temperature = 0.2, numberOfChoices = 3).default(base)

// 手写一份完整参数组,验证合并结果与它相等。
val handwritten = LLMParams(
    temperature = 0.2,
    maxTokens = 150,
    toolChoice = LLMParams.ToolChoice.Auto,
    numberOfChoices = 3
)
println("default() merge equals handwritten: ${overridden == handwritten}")

default() 的语义是"当前实例里为 null 的字段用 default 补齐"。实测它与手写完整参数组相等:

text 复制代码
default() merge equals handwritten: true

注意 default() 是"按字段补空",不是深合并------additionalProperties 整个字段要么用覆盖组的、要么用默认组的,不会按键合并。

三、流式 API:以 Frame 为单位的增量响应

3.1 两个入口,先分清

第三篇 §3.1 提过流式的存在:executor 层的 executeStreaming(),直接对 prompt 发起流式调用。本章是另一个入口------Agent 会话层 的 requestLLMStreaming(),在写会话内调用,返回 Flow<StreamFrame>。两者的 frame 体系相同,但会话层入口能和 prompt 修改、工具调用放在一起用,是 Agent 开发中的主要用法。

3.2 Frame 体系

StreamFrame 分三类:

类别 Frame 含义
Delta(增量) TextDelta 增量文本,实时渲染用
Delta ReasoningDelta 增量推理文本与摘要(仅支持推理的模型)
Delta ToolCallDelta 部分工具调用
Complete(完整) TextComplete 完整文本
Complete ReasoningComplete 完整推理内容
Complete ToolCallComplete 完整工具调用(含参数 JSON)
结束标记 End finishReason + 响应元信息

分工原则:delta 用于实时显示,complete 用于取最终数据 。End 是流结束的标记,带 finishReason,可以据此判断流为什么结束。

3.3 收集一个流

下面直接使用 DeepSeek 的真实客户端。完整程序保存为 StreamingDemo.kt,填入自己的密钥后运行。程序只发起一次流式请求:用 onEach 观察并打印 frame,再由 collectText() 消费同一条流,得到完整文本,不会为了展示两种用法而重复请求。

kotlin 复制代码
import ai.koog.agents.core.agent.AIAgent
import ai.koog.agents.core.dsl.builder.*
import ai.koog.prompt.executor.clients.deepseek.DeepSeekLLMClient
import ai.koog.prompt.executor.clients.deepseek.DeepSeekModels
import ai.koog.prompt.executor.llms.MultiLLMPromptExecutor
import ai.koog.prompt.streaming.StreamFrame
import ai.koog.prompt.streaming.collectText
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val executor = MultiLLMPromptExecutor(DeepSeekLLMClient(apiKey = "xxxxxxx"))
    val graph = strategy<String, String>("streaming-demo") {
        val streamNode by node<String, String> { input ->
            val before = llm.readSession { prompt.messages.size }
            val fullText = llm.writeSession {
                appendPrompt { user(input) }
                // 只发起一次请求:onEach 观察 frame,collectText 拼接同一条流。
                requestLLMStreaming().onEach { frame ->
                    when (frame) {
                        is StreamFrame.TextDelta -> print(frame.text)
                        is StreamFrame.End -> println("\n[END] reason=${frame.finishReason}")
                        else -> {}
                    }
                }.collectText()
            }
            val after = llm.readSession { prompt.messages.size }
            check(after == before + 1)
            println("prompt messages: $before -> $after")
            println("collectText: $fullText")
            fullText
        }
        nodeStart then streamNode then nodeFinish
    }
    val agent = AIAgent(
        executor, DeepSeekModels.DeepSeekV4Flash, strategy = graph,
        systemPrompt = "你是中文助手。回答简洁,不超过80个汉字。"
    )
    try {
        agent.run("用一句话介绍 Kotlin 协程。")
    } finally {
        agent.close()
        executor.close()
    }
    Unit
}

一次真实运行的输出如下;模型措辞和 chunk 划分可能变化,不能把这段内容当成固定断言:

text 复制代码
Kotlin 协程是一种轻量级并发方案,用挂起函数以同步写法实现异步任务,减少线程开销和回调嵌套。
[END] reason=stop
prompt messages: 1 -> 2
collectText: Kotlin 协程是一种轻量级并发方案,用挂起函数以同步写法实现异步任务,减少线程开销和回调嵌套。

TextDelta 按到达顺序打印,End 给出服务端结束原因。消息数从 1 变为 2,只增加了 appendPrompt 追加的 user 消息:流式响应不会自动写回 prompt 历史 ,与第六篇写会话内的普通 requestLLM() 不同。要继续多轮对话,需要按自己的历史策略显式追加最终响应;只打印或返回 fullText 不等于保存它。

filterTextOnly() 将 frame 流转换为仅包含 TextDelta.text 的文本块流;collectText() 则内部提取这些增量并拼接为完整文本。已有 UI 如果只消费字符串,可以选择 frames.filterTextOnly().collect { ... },作为上面终端消费方式的替代,而不是在同一响应后再次调用 requestLLMStreaming()。对模型返回的冷流重新收集也可能重新发起网络请求,不要把它当成可重复读取的缓存。

3.4 流式结构化:Markdown 流解析

如果模型的输出天然是 Markdown(比如列表),可以在流式过程中边收边解析成对象。1.3.0 提供三件套:

  1. MarkdownStructureDefinition:定义输出应遵循的 Markdown 结构,带 schema 和 examples。
  2. markdownStreamingParser { }:注册各类处理器------onHeader(level)、onBullet、onCodeBlock、onLineMatching(Regex)、onFinishStream。
  3. parseStream(文本流):把文本流喂给解析器,解析器闭包内可以 emit 结果对象。

下面的 MarkdownStreamDemo.kt 同样调用真实 DeepSeek。结构说明用于引导模型,解析器负责从增量文本中提取对象,并检查每本书恰好有作者、简介两个非空列表项。要求不满足时抛异常,不用空字符串掩盖缺字段。

kotlin 复制代码
import ai.koog.agents.core.agent.AIAgent
import ai.koog.agents.core.dsl.builder.*
import ai.koog.prompt.executor.clients.deepseek.DeepSeekLLMClient
import ai.koog.prompt.executor.clients.deepseek.DeepSeekModels
import ai.koog.prompt.executor.llms.MultiLLMPromptExecutor
import ai.koog.prompt.markdown.markdown
import ai.koog.prompt.streaming.StreamFrame
import ai.koog.prompt.streaming.filterTextOnly
import ai.koog.prompt.structure.markdown.MarkdownStructureDefinition
import ai.koog.prompt.structure.markdown.markdownStreamingParser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable

@Serializable
data class Book(val title: String, val author: String, val description: String)

fun markdownBookDefinition(): MarkdownStructureDefinition {
    return MarkdownStructureDefinition(
        "bookList",
        schema = {
            markdown {
                header(1, "title")
                bulleted {
                    item("author")
                    item("description")
                }
            }
        },
        examples = {
            markdown {
                header(1, "The Great Gatsby")
                bulleted {
                    item("F. Scott Fitzgerald")
                    item("A novel set in the Jazz Age.")
                }
            }
        }
    )
}

fun parseMarkdownStreamToBooks(markdownStream: Flow<StreamFrame>): Flow<Book> = flow {
    markdownStreamingParser {
        var currentBookTitle = ""
        val bulletPoints = mutableListOf<String>()
        onHeader(1) { headerText ->
            // 收到新标题时,先把上一本书发出去。
            if (currentBookTitle.isNotBlank()) {
                require(bulletPoints.size == 2 && bulletPoints.all { it.isNotBlank() }) {
                    "Book '$currentBookTitle' requires nonblank author and description"
                }
                emit(Book(currentBookTitle, bulletPoints[0], bulletPoints[1]))
            }
            currentBookTitle = headerText
            bulletPoints.clear()
        }
        onBullet { bulletText ->
            require(currentBookTitle.isNotBlank()) { "Bullet found before book title" }
            bulletPoints.add(bulletText)
        }
        onFinishStream {
            if (currentBookTitle.isNotBlank()) {
                require(bulletPoints.size == 2 && bulletPoints.all { it.isNotBlank() }) {
                    "Book '$currentBookTitle' requires nonblank author and description"
                }
                emit(Book(currentBookTitle, bulletPoints[0], bulletPoints[1]))
            }
        }
    }.parseStream(markdownStream.filterTextOnly())
}

fun main(args: Array<String>) = runBlocking {
    val executor = MultiLLMPromptExecutor(DeepSeekLLMClient(apiKey = "xxxxxxx"))
    val graph = strategy<String, List<Book>>("md-stream-demo") {
        val streamNode by node<String, List<Book>> { input ->
            val books = mutableListOf<Book>()
            llm.writeSession {
                appendPrompt { user(input) }
                val mdStream = requestLLMStreaming(markdownBookDefinition())
                parseMarkdownStreamToBooks(mdStream).collect { book ->
                    books.add(book)
                    println("Parsed Book: ${book.title} by ${book.author}")
                }
            }
            books
        }
        nodeStart then streamNode then nodeFinish
    }
    val agent = AIAgent(executor, DeepSeekModels.DeepSeekV4Flash, strategy = graph, systemPrompt = "你是图书推荐助手。严格使用要求的 Markdown 格式:每本书一个一级标题,恰好两个列表项,分别是作者和一句话简介。不要前言、结语、字段标签或代码围栏。")
    try {
        val result = agent.run("推荐两本科幻小说,作者和简介用中文,每本简介不超过30个汉字。")
        check(result.size == 2)
        println("RESULT books=$result")
    } finally { agent.close(); executor.close() }
}

requestLLMStreaming(markdownBookDefinition()) 会把结构说明带入请求,但不保证模型始终遵守。一次真实运行得到:

text 复制代码
Parsed Book: 三体 by 刘慈欣
Parsed Book: 流浪地球 by 刘慈欣
RESULT books=[Book(title=三体, author=刘慈欣, description=地球文明与三体文明展开生死博弈。), Book(title=流浪地球, author=刘慈欣, description=人类推动地球逃离太阳系寻找新家园。)]

这次模型输出满足了格式与数量检查;其他响应仍可能缺字段、增加列表项或使用不同标题层级,业务侧需要处理解析失败,并对书籍信息做进一步校验。生成的书目和简介也不能因为已经变成 Book 就视为事实已核实。

解析器会拼接不同 chunk,真实网络增量不必按行对齐。onFinishStream 在这份实现中负责发出最后一本书;1.3.0 的 Markdown 解析器按章节缓冲,因此"边收边解析"不等于每个 token 都立即产生一个对象,也不保证下一个标题刚到达时上一本书就立刻发出。

3.5 流式与工具组合

解析出的对象可以直接喂给工具:在解析器的 collect 里调用 callToolRaw(BookTool.NAME, book),或者用 toParallelToolCallsRaw(BookTool::class) 并行调用。官方文档的 Book 工具示例演示了这条路:流式产出结构化对象 → 立即触发工具执行,不用等整个响应结束。

工具定义与注册见第四篇,本篇不再展开完整组合代码。离线预置流同样可以驱动解析器并触发测试工具,不必依赖真实模型;不过本文没有验证这条组合路径。生产中还需考虑解析失败、重复执行与工具副作用:如果末尾解析失败,前面已执行的工具不会因此自动回滚。

3.6 事件钩子里的流

Agent 的事件处理器能监听流本身:onLLMStreamingFrameReceived(每个 frame 到达)、onLLMStreamingFailed、onLLMStreamingCompleted。这属于事件体系的用法,第六篇 1.4 的 feature 机制正是它们的挂载点,第八篇讲可观测性时一并展开。

四、结构化输出:让响应变成可解析的对象

4.1 定义数据结构

结构化输出的第一步是用 data class 描述你要的对象:

kotlin 复制代码
import ai.koog.agents.core.tools.annotations.LLMDescription
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
@SerialName("SimpleWeatherForecast")
@LLMDescription("Simple weather forecast for a location")
data class SimpleWeatherForecast(
    @property:LLMDescription("Location name")
    val location: String,
    @property:LLMDescription("Temperature in Celsius")
    val temperature: Int,
    @property:LLMDescription("Weather conditions (e.g., sunny, cloudy, rainy)")
    val conditions: String
)

三个注解各司其职:@Serializable 是 kotlinx.serialization 的要求;@SerialName 指定序列化名称;@LLMDescription 给模型看的类与字段说明------字段上要用 @property:LLMDescription(注解目标是 property 而非字段)。

支持的结构远比这个例子丰富:嵌套类、List/Map 集合、枚举、sealed class 多态都可以;还可以提供 examples 帮模型理解格式。

4.2 离线生成一次 Schema

JsonStructure.create 能从 data class 直接生成 JSON Schema,这一步不需要模型。下面是解释生成过程的局部片段;4.4 会给出包含这些内容的完整 StructuredDemo.kt,运行时使用完整版本即可,不要重复拼接两份定义:

kotlin 复制代码
import ai.koog.prompt.structure.json.JsonStructure
import ai.koog.prompt.structure.json.generator.BasicJsonSchemaGenerator

// 局部片段:使用 4.1 的数据类;这些内容也会包含在 4.4 的完整程序中。
val exampleForecasts = listOf(
    SimpleWeatherForecast("Amsterdam", 15, "Rainy"),
    SimpleWeatherForecast("Paris", 12, "Cloudy")
)
val structure = JsonStructure.create<SimpleWeatherForecast>(
    schemaGenerator = BasicJsonSchemaGenerator.Default,
    examples = exampleForecasts
)
println("SCHEMA: ${structure.schema}")

实际输出(关键部分):

text 复制代码
SCHEMA: Basic(name=SimpleWeatherForecast, schema={"type":"object","properties":{"location":{"type":"string","description":"Location name"},"temperature":{"type":"integer","description":"Temperature in Celsius"},"conditions":{"type":"string","description":"Weather conditions (e.g., sunny, cloudy, rainy)"}},"required":["location","temperature","conditions"],"additionalProperties":false})

注意 @property:LLMDescription 的文字被逐字段写进了 schema 的 description,三个字段全部进入 required。这说明类型与注解可以转换为结构约束,但上面的输出特指 Basic generator。高层 API 会按模型能力和 provider 选择相应 generator 与 native/manual 模式,不保证直接使用与此完全相同的 schema。

4.3 三个使用层

层 API 场景
executor 层 promptExecutor.executeStructured<T>(prompt, model, ...) 独立的单次请求,不经过 Agent
会话层 requestLLMStructured<T>()(写会话内) Agent 流程中的某个点需要结构化数据
节点层 nodeLLMRequestStructured<T>()(by 委托) 可复用的结构化节点

三层的返回值统一为 Result<StructuredResponse<T>>,用 isSuccess / getOrNull()?.data 取值。会话层和节点层都在图流程内,与第五篇的策略体系衔接。

返回 Result 不代表调用过程中不会直接抛异常。 1.3.0 的 executor 实现将解析与修复放在 runCatching 内,而 schema/config 准备和初次 execute 位于其外。因此,配置或首次模型请求失败仍可能直接抛出;解析失败通常表现为 Result.failure,修复调用中的异常也可能落入这个失败结果。业务调用方既要处理返回的 Result,也要处理调用本身抛出的异常;协程取消应继续传播。

4.4 解析失败怎么办:StructureFixingParser

JSON 可能语法正确,却无法反序列化为目标类型,例如把整数写成"不是数字"。StructureFixingParser 在遇到 SerializationException 时,会将原内容和解析异常交给修复模型,最多额外尝试 retries 次;其他异常不会一律进入修复循环。修复模型可以与初次请求使用同一个模型。

下面是完整的 StructuredDemo.kt,全部请求使用真实 DeepSeek,不需要任何替身类。为方便直接运行,代码重复列出了 4.1 的数据类,并包含 4.2 的 schema 生成过程。

程序演示两条路径:先用 executeStructured 请求一个虚构天气对象,并配置解析失败时的自动修复;再显式传入一段人为写坏的 JSON,调用 StructureFixingParser.parse,让真实模型执行修复。第二条路径不用等模型偶然出错,也没有伪造模型返回内容。

kotlin 复制代码
import ai.koog.agents.core.tools.annotations.LLMDescription
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.executor.clients.deepseek.DeepSeekLLMClient
import ai.koog.prompt.executor.clients.deepseek.DeepSeekModels
import ai.koog.prompt.executor.llms.MultiLLMPromptExecutor
import ai.koog.prompt.executor.model.StructureFixingParser
import ai.koog.prompt.executor.model.executeStructured
import ai.koog.prompt.structure.json.JsonStructure
import ai.koog.prompt.structure.json.generator.BasicJsonSchemaGenerator
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
@SerialName("SimpleWeatherForecast")
@LLMDescription("Simple weather forecast for a location")
data class SimpleWeatherForecast(
    @property:LLMDescription("Location name")
    val location: String,
    @property:LLMDescription("Temperature in Celsius")
    val temperature: Int,
    @property:LLMDescription("Weather conditions (e.g., sunny, cloudy, rainy)")
    val conditions: String
)

val exampleForecasts = listOf(
    SimpleWeatherForecast("Amsterdam", 15, "Rainy"),
    SimpleWeatherForecast("Paris", 12, "Cloudy")
)

fun main() = runBlocking {
    val model = DeepSeekModels.DeepSeekV4Flash
    val executor = MultiLLMPromptExecutor(DeepSeekLLMClient(apiKey = "xxxxxxx"))
    try {
        // schema 的生成是本地操作;下面的请求和修复才会调用真实模型。
        val structure = JsonStructure.create<SimpleWeatherForecast>(
            schemaGenerator = BasicJsonSchemaGenerator.Default,
            examples = exampleForecasts
        )
        println("SCHEMA: ${structure.schema}")

        val result = executor.executeStructured<SimpleWeatherForecast>(
            prompt = prompt("structured") {
                system("Return a fictional weather forecast, not live weather data.")
                user("Give a fictional weather forecast for Paris, using Celsius.")
            },
            model = model,
            examples = exampleForecasts,
            fixingParser = StructureFixingParser(model = model, retries = 2)
        )
        println("Forecast: ${result.getOrThrow().data}")

        // 不靠运气等模型出错:明确使用人为写坏的 JSON,演示真实修复调用。
        val badJson = """{"location":"Paris","temperature":"不是数字","conditions":"Cloudy"}"""
        println("Repair input: $badJson")
        val repaired = StructureFixingParser(model = model, retries = 2)
            .parse(executor, structure, badJson)
        println("Repaired: $repaired")
    } finally {
        executor.close()
    }
    Unit
}

一次真实运行的结果如下(省略与 4.2 相同的 SCHEMA 行):

text 复制代码
Forecast: SimpleWeatherForecast(location=Paris, France, temperature=18, conditions=Partly cloudy)
Repair input: {"location":"Paris","temperature":"不是数字","conditions":"Cloudy"}
Repaired: SimpleWeatherForecast(location=Paris, temperature=12, conditions=Cloudy)

第一条路径已经返回了可解析对象,但仅凭这行输出不能判断内部有没有发生自动修复。第二条路径的 Repair input 是程序明确写入的错误样本,Repaired 才是真实模型修复后的解析结果。这个字符串只说明原值不是数字,没有给出真实温度,所以输出中的 12 是模型补出的值,不是恢复了一个已经存在的正确答案。不同运行可能得到不同值,不能对它做固定数值断言。

需要记住两个边界:

  • 修复会增加模型调用与费用 。本程序除了首次结构化请求,还额外演示了一条真实修复路径;每条路径都可能重试。retries = 2 表示最多额外尝试两次,不是整段程序只调用两次。
  • 修复数据不等于替换原响应 。若在 executeStructured 内触发了自动修复,StructuredResponse.data 可以来自修复后的文本,但 message 仍是初次响应。写会话成功时追加的也是这个原始 message,历史中的坏 JSON 不会因此自动被替换。本例显式调用的 parse 只返回对象,本身不负责保存会话历史。

这里的天气请求已明确要求虚构数据,没有接入天气工具或外部数据源;它演示的是类型解析,不是实时天气查询。getOrThrow() 用来让示例在失败时直接暴露问题,业务代码仍需按 4.3 区分调用异常与失败结果,并校验字段的业务含义。

4.5 自动选择与手动控制

上面用的都是简化 API:框架根据模型能力自动决定用哪种 schema(Basic/Standard)和哪种模式(native------provider 原生结构化输出;manual------把 schema 说明注入 prompt)。需要精细控制时用 StructuredRequestConfig:按 provider 指定不同配置、手动选择 schema 生成器(StandardJsonSchemaGenerator / BasicJsonSchemaGenerator / provider 特定生成器)、设置回退策略。

第二章 2.4 的 LLMParams.schema 与这一章的关系至此可以收拢:schema 字段是"手动把 schema 塞进参数"的底层路径;requestLLMStructured 是"从 data class 自动生成 schema 并处理解析"的高层路径。日常业务用后者,需要对齐特定 provider 的 schema 方言时才下沉到前者。

五、选型与成本:一次调用怎么组合

5.1 输出方式的决策表

需求 选择 依据
完整文本即可 普通 requestLLM() 最简单,响应自动入历史
界面逐字显示 requestLLMStreaming + TextDelta 用户体验取决于首字延迟
流式产出结构化对象 流式 + MarkdownDefinition + parser 边收边处理,适合列表型内容
后续流程要读字段 requestLLMStructured 拿类型安全的对象,可能触发修复调用
临时不要工具 requestLLMWithoutTools() 不提供工具;与 ToolChoice.None 的区别见 2.3

输入是否带附件与输出如何消费是两个设计维度,但具体组合能否使用,仍要核对 provider、模型及 API 路径是否同时支持相应能力。

5.2 两种缓存,别混

第三章的调用可能很贵,缓存能省钱,但 Koog 里"缓存"有两个完全不同的东西:

响应缓存(第 3 篇 §7) 提示词缓存(本节)
存什么 完整的 LLM 响应 prompt 前缀(provider 侧存储)
在哪存 本地,包装 executor provider 服务器
命中条件 按缓存键规则匹配 prompt 与工具等信息;消息元信息会被清理 相同可缓存前缀,并满足服务端缓存条件
收益 相同请求跳过网络调用 重复前缀省 token 计费与延迟
典型场景 固定任务的重复调用 多轮对话、超长 system prompt、固定工具定义

响应缓存已经在第三篇讲过(CachedPromptExecutor 包装)。提示词缓存是 provider 侧能力。1.3.0 为 Anthropic 和 Amazon Bedrock 提供了相应集成,但开启路径与缓存标记类型并不通用。下面先讲 Anthropic;Bedrock 的区别在本节末尾说明。

Anthropic 有两种方式。请求级自动缓存通过 AnthropicParams.cacheControl 配置,由 Anthropic 服务端按自动缓存规则管理断点,适合多轮对话:

kotlin 复制代码
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.executor.clients.anthropic.AnthropicParams
import ai.koog.prompt.executor.clients.anthropic.AnthropicCacheControl

fun autoCachePrompt() {
    val params = AnthropicParams(cacheControl = AnthropicCacheControl.Default)
    val prompt = prompt("assistant", params = params) {
        system("You are a helpful assistant with a very long system prompt...")
        user("What can you help me with?")
    }
    println("params=${prompt.params}")
}

实测输出(节选):

text 复制代码
params=AnthropicParams(..., automaticCacheControl=Default)

逐块手动缓存------把断点放在指定消息上,其前所有内容都可缓存,可为 system 指定一小时 TTL:

kotlin 复制代码
fun manualCachePrompt() {
    val prompt = prompt("assistant") {
        system("You are a knowledgeable assistant...", cache = AnthropicCacheControl.OneHour)
        user("Summarize the latest AI research.")
    }
    println("parts=${prompt.messages.first().parts}")
}

实测输出:

text 复制代码
parts=[Text(text=You are a knowledgeable assistant..., cacheControl=OneHour)]

这里有一个容易误读的输出细节:1.3.0 的构造参数和公开属性都叫 cacheControl ,只是 toString() 使用了 automaticCacheControl 这个标签。这不是 API 改名,也不能据此认定文档使用了新版参数。

块级标记落在消息部件上:MessagePart.Text 和附件部件都可以带 cacheControl,Anthropic 的图片、文档等内容也可以设置缓存断点。标记表示请求使用缓存,并不证明已经命中;最低可缓存长度、可用模型、TTL 和计费等规则仍以服务端文档为准。这里的短 prompt 仅验证配置构造,不是缓存命中或节省费用的实验。

Bedrock 要按具体调用路径区分:其 Converse API 可以将消息部件和工具上的 BedrockCacheControl 转换成 cache point,并非照搬 Anthropic 的请求级开关。Anthropic 使用 AnthropicCacheControl,Bedrock Converse 使用 BedrockCacheControl;标记类型与客户端不匹配会抛异常,不能把带 Anthropic 标记的 prompt 原样交给另一 provider。

5.3 叠加的代价

这些能力可以在支持相应组合的 provider、模型与 API 路径上按需搭配,不能默认任意组合都可用。每叠一层,验证成本都在涨:流式里做结构化解析要处理跨 chunk 边界;结构化加修复要多付模型调用;多模态要让每个 provider 确认附件支持。建议按需逐层引入,每加一层就把对应路径重新验证一遍。

后记

到这里,模型调用层的全部内容讲完了:第三篇的骨架(Prompt、Client、Executor),加上本篇的输入形态、控制参数和三种输出方式。

一张表收一下:

能力 记住什么
多模态消息 四类附件函数;1.3.0 用 AttachmentSource,path 是 kotlinx.io.files.Path;支持因 provider 而异
LLMParams 八个字段,构造期校验;写会话内 changeLLMParams 随会话提交
流式 delta 实时显示、complete 取最终数据;响应不自动入历史
流式结构化 Markdown 三件套,边收边解析成对象
结构化输出 三层返回 Result<StructuredResponse<T>>,但准备与首次调用仍可能抛异常;修复数据不等于替换原响应
缓存 响应缓存省重复请求,提示词缓存省重复前缀,别混用

选择困难时回到那个基本问题:这次调用,输入要带什么、输出给谁看、要不要能被程序读。答案定了,API 就定了。

下一篇进入工程化主题:如何观察 Agent 的执行过程(事件、tracing、OpenTelemetry),以及如何测试一个包含模型调用的流程。

本文第三章的两个流式程序和第四章的结构化、显式修复程序均已使用 DeepSeek 实际执行,文中输出来自这些程序的一次运行,不是预置响应。第一章只构造附件消息:当前示例模型不支持其中的附件类型,不能把消息构造成功说成完成了多模态识别。schema 生成、参数校验与缓存配置构造仍属于本地操作,不需要为了演示而发送模型请求;Anthropic/Bedrock 的服务端缓存命中和流式加工具的组合不在本次验证范围内。

参考资料

相关推荐
四六的六1 小时前
一次改回去越界的沙箱复盘:配置恢复了,token 还活着,检测器漏了最关键的那条
人工智能·openai·agent·个人开发·ai编程·ai大模型·ai产品
阿里云云原生1 小时前
Agent 一直在行动,任务为何没有进展?PoS 识别并打破 Belief Trapping
agent
FlyWIHTSKY2 小时前
AI Agent 工程师进阶表
人工智能·agent
小花皮猪2 小时前
从 Prompt 到结构化 JSON:如何利用 Bright Data 快速搭建 YouTube 监测工作流?
人工智能·爬虫·agent·数据采集
hpoenixf2 小时前
让 Agent 真正学会计算:从 Python 工具到计算工作台的架构演进
agent
武子康2 小时前
同为7/8,错误样本却不同:Agent Grader 分歧复算
人工智能·后端·agent
2401_872418783 小时前
把 Ubuntu 服务器变成 AI Agent 终端:用自然语言或语音管理 Linux,任何命令都先审批(附完整 Python 源码)
linux·服务器·ubuntu·agent
京东云开发者3 小时前
ScoAI:面向供应链的运筹原生决策智能
llm
goehou3 小时前
Prompt 管理实战:分层、版本与回归上线(附清单)
llm·实战·prompt·测试·工程化