前言
上一篇文章解决了"上下文从哪来、任务从哪继续"。但一次模型调用本身还有两个问题没有讲完:输入只能是一段文字吗?输出只能拿到完整结果吗?
这篇把这两个问题补齐,同时兑现前面留下的三个承诺。第三篇讲提示词参数时说过"详细内容将在后面的文章讲解";第三篇讲流式响应时说过"流式 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()附件的真实调用会直接抛出:
textIllegalArgumentException: 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 提供三件套:
MarkdownStructureDefinition:定义输出应遵循的 Markdown 结构,带 schema 和 examples。markdownStreamingParser { }:注册各类处理器------onHeader(level)、onBullet、onCodeBlock、onLineMatching(Regex)、onFinishStream。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 的服务端缓存命中和流式加工具的组合不在本次验证范围内。
参考资料
- Streaming API: https://docs.koog.ai/streaming-api/
- Structured output: https://docs.koog.ai/structured-output/
- Multimodal content: https://docs.koog.ai/prompts/prompt-creation/multimodal-content/
- LLM parameters: https://docs.koog.ai/llm-parameters/
- Prompt caching control: https://docs.koog.ai/prompts/prompt-creation/cache-control/
- LLM response caching: https://docs.koog.ai/prompts/llm-response-caching/
- Module versioning: https://docs.koog.ai/module-versioning/