Koog Streaming API 스펙 번역
원문: Koog Documentation — Streaming API 이 글은 Koog 공식 Streaming API 문서를 가능한 한 원문 구조와 코드 예제를 유지해 한국어로 옮긴 번역본입니다.
Streaming API
Koog의 Streaming API를 사용하면 Kotlin에서는 Flow<StreamFrame>으로, Java에서는 Flow.Publisher<StreamFrame>으로 LLM 출력을 점진적으로 소비할 수 있습니다.
전체 응답을 기다리는 대신, 코드에서 다음을 수행할 수 있습니다:
- 도착하는 즉시 어시스턴트 텍스트 렌더링,
- 도구 호출을 실시간으로 감지하고 처리,
- 스트림이 언제, 어떤 이유로 종료되는지 확인.
스트림은 두 가지 범주로 구성된 타입이 지정된 프레임을 전달합니다:
Kotlin
Delta 프레임(증분/부분 콘텐츠):
StreamFrame.TextDelta(text: String, index: Int?)— 증분 어시스턴트 텍스트StreamFrame.ReasoningDelta(text: String?, summary: String?, index: Int?)— 증분 추론 텍스트와 요약StreamFrame.ToolCallDelta(id: String?, name: String?, content: String?, index: Int?)— 부분 도구 호출
Complete 프레임(전체 콘텐츠):
StreamFrame.TextComplete(text: String, index: Int?)— 완전한 어시스턴트 텍스트StreamFrame.ReasoningComplete(text: List<String>, summary: List<String>?, encrypted: String?, index: Int?)— 선택적 요약 및 암호화된 콘텐츠를 포함한 완전한 추론StreamFrame.ToolCallComplete(id: String?, name: String, content: String, index: Int?)— 완전한 도구 호출
종료 마커:
StreamFrame.End(finishReason: String?, metaInfo: ResponseMetaInfo)— 응답 메타데이터가 포함된 스트림 종료 마커
Java
Delta 프레임(증분/부분 콘텐츠):
StreamFrame.TextDelta— 증분 어시스턴트 텍스트. 필드:getText(),getIndex().StreamFrame.ReasoningDelta— 증분 추론 텍스트와 요약. 필드:getText(),getSummary(),getIndex().StreamFrame.ToolCallDelta— 부분 도구 호출. 필드:getId(),getName(),getContent(),getIndex().
Complete 프레임(전체 콘텐츠):
StreamFrame.TextComplete— 완전한 어시스턴트 텍스트. 필드:getText(),getIndex().StreamFrame.ReasoningComplete— 선택적 요약 및 암호화된 콘텐츠를 포함한 완전한 추론. 필드:getText()(List<String>반환),getSummary()(List<String>반환),getEncrypted(),getIndex().StreamFrame.ToolCallComplete— 완전한 도구 호출. 필드:getId(),getName(),getContent(),getIndex(). JSON 파싱을 위해getContentJson()및getContentJsonResult()도 제공합니다.
종료 마커:
StreamFrame.End— 스트림 종료 마커. 필드:getFinishReason(),getMetaInfo().
일반 텍스트를 추출하고, 프레임을 Message.Response 객체로 변환하며, 조각난 도구 호출을 안전하게 결합할 수 있도록 헬퍼가 제공됩니다.
API 개요
스트리밍을 사용하면 다음을 수행할 수 있습니다:
- 데이터가 도착하는 즉시 처리(UI 응답성 향상)
- 구조화된 정보를 즉석에서 파싱(Markdown/JSON 등)
- 객체가 완성되는 즉시 방출
- 도구를 실시간으로 트리거
- 지원 모델에서 모델 추론을 실시간으로 접근
프레임 자체를 대상으로 작업하거나, 프레임에서 파생된 일반 텍스트를 대상으로 작업할 수 있습니다.
Delta 프레임과 Complete 프레임
스트리밍 API는 두 가지 유형의 프레임을 구분합니다:
Delta 프레임(
DeltaFrame) — 청크 단위로 도착하는 증분/부분 콘텐츠입니다. 콘텐츠가 스트리밍되는 동안 실시간 표시를 하는 데 적합합니다. 예:TextDelta,ReasoningDelta,ToolCallDelta.Complete 프레임(
CompleteFrame) — 해당 콘텐츠 유형의 모든 델타를 받은 뒤 방출되는 전체 콘텐츠입니다. 최종 처리와Message.Response객체로의 변환에 유용합니다. 예:TextComplete,ReasoningComplete,ToolCallComplete.
일반적으로 UI 업데이트에는 delta 프레임을 사용하고, 최종 구조화 데이터를 추출할 때는 complete 프레임을 사용합니다.
사용법
프레임을 직접 다루기
가장 일반적인 접근 방식입니다. 각 프레임 종류에 반응합니다.
Kotlin
1llm.writeSession {2 appendPrompt { user("Tell me a joke, then call a tool with JSON args.") }34 val stream = requestLLMStreaming() // Flow<StreamFrame>56 stream.collect { frame ->7 when (frame) {8 is StreamFrame.TextDelta -> print(frame.text)9 is StreamFrame.ReasoningDelta -> print("[Reasoning] text=${frame.text} summary=${frame.summary}")10 is StreamFrame.ToolCallComplete -> {11 println("\n🔧 Tool call: ${frame.name} args=${frame.content}")12 // Optionally parse lazily:13 // val json = frame.contentJson14 }15 is StreamFrame.End -> println("\n[END] reason=${frame.finishReason}")16 else -> {} // Handle other frame types (TextComplete, ToolCallDelta, etc.)17 }18 }19}Java
1ctx.getLlm().writeSession(session -> {2 session.appendPrompt(prompt -> {3 prompt.user("Tell me a joke, then call a tool with JSON args.");4 return null;5 });67 Flow.Publisher<StreamFrame> stream = session.requestLLMStreaming();89 stream.subscribe(new Flow.Subscriber<>() {10 @Override11 public void onSubscribe(Flow.Subscription subscription) {12 subscription.request(Long.MAX_VALUE);13 }1415 @Override16 public void onNext(StreamFrame frame) {17 if (frame instanceof StreamFrame.TextDelta delta) {18 System.out.print(delta.getText());19 } else if (frame instanceof StreamFrame.ReasoningDelta reasoning) {20 System.out.print("[Reasoning] text=" + reasoning.getText()21 + " summary=" + reasoning.getSummary());22 } else if (frame instanceof StreamFrame.ToolCallComplete toolCall) {23 System.out.println("\nTool call: " + toolCall.getName()24 + " args=" + toolCall.getContent());25 } else if (frame instanceof StreamFrame.End end) {26 System.out.println("\n[END] reason=" + end.getFinishReason());27 }28 // Handle other frame types (TextComplete, ToolCallDelta, etc.)29 }3031 @Override32 public void onError(Throwable throwable) {33 System.err.println("Stream error: " + throwable.getMessage());34 }3536 @Override37 public void onComplete() {38 }39 });4041 return null;42});원시 문자열 스트림을 직접 다루어 출력을 파싱할 수도 있다는 점이 중요합니다. 이 접근 방식은 파싱 프로세스에 대해 더 많은 유연성과 제어권을 제공합니다.
다음은 출력 구조의 Markdown 정의를 사용하는 원시 문자열 스트림입니다:
Kotlin
1fun markdownBookDefinition(): MarkdownStructureDefinition {2 return MarkdownStructureDefinition("name", schema = { /*...*/ })3}45val mdDefinition = markdownBookDefinition()67llm.writeSession {8 val stream = requestLLMStreaming(mdDefinition)9 // Access the raw string chunks directly10 stream.collect { chunk ->11 // Process each chunk of text as it arrives12 println("Received chunk: $chunk") // The chunks together will be structured as a text following the mdDefinition schema13 }14}Java
1StructureDefinition mdDefinition = markdownBookDefinition();23ctx.getLlm().writeSession(session -> {4 session.appendPrompt(prompt -> {5 prompt.user(input);6 });78 Flow.Publisher<StreamFrame> stream = session.requestLLMStreaming(mdDefinition);910 // Access the raw frames directly11 stream.subscribe(new Flow.Subscriber<>() {12 @Override13 public void onSubscribe(Flow.Subscription subscription) {14 subscription.request(Long.MAX_VALUE);15 }1617 @Override18 public void onNext(StreamFrame frame) {19 // Process each frame as it arrives20 System.out.println("Received frame: " + frame);21 }2223 @Override24 public void onError(Throwable throwable) {25 System.err.println("Stream error: " + throwable.getMessage());26 }2728 @Override29 public void onComplete() {30 }31 });3233 return null;34});추론 프레임 다루기
Claude Sonnet 4.5 또는 GPT-o1처럼 추론을 지원하는 모델은 스트리밍 중에 추론 프레임을 방출합니다. 추론 과정과 그 요약 모두에 접근할 수 있습니다:
Kotlin
1llm.writeSession {2 appendPrompt { user("Solve this complex problem: ...") }34 val stream = requestLLMStreaming()5 val reasoningSteps = mutableListOf<String>()6 val summarySteps = mutableListOf<String>()78 stream.collect { frame ->9 when (frame) {10 is StreamFrame.ReasoningDelta -> {11 frame.text?.let { 12 reasoningSteps.add(it)13 print(frame.text) // Display reasoning as it arrives14 }15 frame.summary?.let {16 summarySteps.add(it)17 print(frame.summary) // Display reasoning summary as it arrives18 }19 }20 is StreamFrame.ReasoningComplete -> {21 // Access complete reasoning22 println("\nComplete reasoning: ${frame.text.joinToString("")}")23 println("Summary: ${frame.summary?.joinToString("") ?: "N/A"}")24 }25 is StreamFrame.TextDelta -> print(frame.text)26 is StreamFrame.End -> println("\n[END]")27 else -> {}28 }29 }30}Java
1ctx.getLlm().writeSession(session -> {2 session.appendPrompt(prompt -> {3 prompt.user("Solve this complex problem: ...");4 return null;5 });67 Flow.Publisher<StreamFrame> stream = session.requestLLMStreaming();8 List<String> reasoningSteps = new ArrayList<>();9 List<String> summarySteps = new ArrayList<>();1011 stream.subscribe(new Flow.Subscriber<StreamFrame>() {12 @Override13 public void onSubscribe(Flow.Subscription subscription) {14 subscription.request(Long.MAX_VALUE);15 }1617 @Override18 public void onNext(StreamFrame frame) {19 if (frame instanceof StreamFrame.ReasoningDelta reasoning) {20 if (reasoning.getText() != null) {21 reasoningSteps.add(reasoning.getText());22 System.out.print(reasoning.getText());23 }24 if (reasoning.getSummary() != null) {25 summarySteps.add(reasoning.getSummary());26 System.out.print(reasoning.getSummary());27 }28 } else if (frame instanceof StreamFrame.ReasoningComplete complete) {29 // Access complete reasoning30 System.out.println("\nComplete reasoning: "31 + String.join("", complete.getText()));32 System.out.println("Summary: "33 + (complete.getSummary() != null34 ? String.join("", complete.getSummary()) : "N/A"));35 } else if (frame instanceof StreamFrame.TextDelta delta) {36 System.out.print(delta.getText());37 } else if (frame instanceof StreamFrame.End) {38 System.out.println("\n[END]");39 }40 }4142 @Override43 public void onError(Throwable throwable) { }4445 @Override46 public void onComplete() { }47 });4849 return null;50});원시 텍스트 스트림으로 작업하기(파생)
Flow<String>을 기대하는 기존 스트리밍 파서가 있다면,
filterTextOnly()로 텍스트 청크를 파생하거나 collectText()로 수집하세요.
Kotlin
1llm.writeSession {2 val frames = requestLLMStreaming()34 // Stream text chunks as they come:5 frames.filterTextOnly().collect { chunk -> print(chunk) }67 // Or, gather all text into one String after End:8 val fullText = frames.collectText()9 println("\n---\n$fullText")10}Java
1ctx.getLlm().writeSession(session -> {2 Flow.Publisher<StreamFrame> frames = session.requestLLMStreaming();34 // Stream text chunks as they come (equivalent of filterTextOnly):5 StringBuilder fullText = new StringBuilder();6 frames.subscribe(new Flow.Subscriber<>() {7 @Override8 public void onSubscribe(Flow.Subscription subscription) {9 subscription.request(Long.MAX_VALUE);10 }1112 @Override13 public void onNext(StreamFrame frame) {14 if (frame instanceof StreamFrame.TextDelta delta) {15 System.out.print(delta.getText());16 fullText.append(delta.getText());17 }18 }1920 @Override21 public void onError(Throwable throwable) { }2223 @Override24 public void onComplete() {25 // fullText now contains all text (equivalent of collectText)26 System.out.println("\n---\n" + fullText);27 }28 });2930 return null;31});이벤트 핸들러에서 스트림 이벤트 수신하기
에이전트 이벤트 핸들러에서 스트림 이벤트를 수신할 수 있습니다.
Kotlin
1handleEvents {2 onToolCallStarting { context ->3 println("\n🔧 Using ${context.toolName} with ${context.toolArgs}... ")4 }56 onLLMStreamingFrameReceived { context ->7 when (val frame = context.streamFrame) {8 is StreamFrame.TextDelta -> print(frame.text)9 is StreamFrame.ReasoningDelta -> print("[Reasoning] text=${frame.text} summary=${frame.summary}")10 else -> {} // Handle other frame types if needed11 }12 }1314 onLLMStreamingFailed { context ->15 println("❌ Error: ${context.error}")16 }1718 onLLMStreamingCompleted {19 println("🏁 Done")20 }21}Java
1.install(EventHandler.Feature, config -> {2 config.onToolCallStarting(ctx -> {3 System.out.println("\nUsing " + ctx.getToolName() + " with " + ctx.getToolArgs() + "... "); });45 config.onLLMStreamingFrameReceived(ctx -> {6 StreamFrame frame = ctx.getStreamFrame();7 if (frame instanceof StreamFrame.TextDelta delta) {8 System.out.print(delta.getText());9 } else if (frame instanceof StreamFrame.ReasoningDelta reasoning) {10 System.out.print("[Reasoning] text=" + reasoning.getText()11 + " summary=" + reasoning.getSummary());12 }13 });1415 config.onLLMStreamingFailed(ctx -> {16 System.out.println("Error: " + ctx.getError());17 });1819 config.onLLMStreamingCompleted(ctx -> {20 System.out.println("Done");21 });22})프레임을 Message.Response로 변환하기
수집한 프레임 목록을 표준 메시지 객체로 변환할 수 있습니다.
toAssistantMessageOrNull()— 텍스트 프레임에서Message.Assistant를 추출합니다toReasoningMessageOrNull()— 추론 프레임에서Message.Reasoning을 추출합니다toToolCallMessages()— 도구 호출 프레임에서Message.Tool.Call을 추출합니다toMessageResponses()— 모든 완성된 프레임을 해당하는Message.Response객체로 변환합니다
예제
스트리밍 중 구조화된 데이터 사용하기(Markdown 예제)
원시 문자열 스트림으로 작업할 수도 있지만, 구조화된 데이터로 작업하는 편이 더 편리한 경우가 많습니다.
구조화된 데이터 접근 방식에는 다음과 같은 핵심 구성 요소가 포함됩니다.
- MarkdownStructureDefinition: Markdown 형식의 구조화된 데이터에 대한 스키마와 예제를 정의하는 데 도움을 주는 클래스입니다.
- markdownStreamingParser: Markdown 청크 스트림을 처리하고 이벤트를 내보내는 파서를 생성하는 함수입니다.
아래 섹션에서는 구조화된 데이터 스트림 처리와 관련된 단계별 지침과 코드 샘플을 제공합니다.
1. 데이터 구조 정의하기
먼저 구조화된 데이터를 표현할 데이터 클래스를 정의합니다.
Kotlin
1@Serializable2data class Book(3 val title: String,4 val author: String,5 val description: String6)Java
1// TODO not yet supported in Java2. Markdown 구조 정의하기
MarkdownStructureDefinition 클래스를 사용해 데이터가 Markdown에서 어떻게 구조화되어야 하는지 지정하는 정의를 만듭니다.
Kotlin
1fun markdownBookDefinition(): MarkdownStructureDefinition {2 return MarkdownStructureDefinition("bookList", schema = {3 markdown {4 header(1, "title")5 bulleted {6 item("author")7 item("description")8 }9 }10 }, examples = {11 markdown {12 header(1, "The Great Gatsby")13 bulleted {14 item("F. Scott Fitzgerald")15 item("A novel set in the Jazz Age that tells the story of Jay Gatsby's unrequited love for Daisy Buchanan.")16 }17 }18 })19}Java
1// TODO not yet supported in Java3. 데이터 구조용 파서 만들기
markdownStreamingParser는 여러 Markdown 요소에 대한 다양한 핸들러를 제공합니다.
Kotlin
1markdownStreamingParser {2 // Handle level 1 headings (level ranges from 1 to 6)3 onHeader(1) { headerText -> }4 // Handle bullet points5 onBullet { bulletText -> }6 // Handle code blocks7 onCodeBlock { codeBlockContent -> }8 // Handle lines matching a regex pattern9 onLineMatching(Regex("pattern")) { line -> }10 // Handle the end of the stream11 onFinishStream { remainingText -> }12}Java
1// TODO not yet supported in Java정의한 핸들러를 사용하면 markdownStreamingParser 함수로 Markdown 스트림을 파싱하고 데이터 객체를 내보내는 함수를 구현할 수 있습니다.
Kotlin
1fun parseMarkdownStreamToBooks(markdownStream: Flow<StreamFrame>): Flow<Book> {2 return flow {3 markdownStreamingParser {4 var currentBookTitle = ""5 val bulletPoints = mutableListOf<String>()67 // Handle the event of receiving the Markdown header in the response stream8 onHeader(1) { headerText ->9 // If there was a previous book, emit it10 if (currentBookTitle.isNotEmpty() && bulletPoints.isNotEmpty()) {11 val author = bulletPoints.getOrNull(0) ?: ""12 val description = bulletPoints.getOrNull(1) ?: ""13 emit(Book(currentBookTitle, author, description))14 }1516 currentBookTitle = headerText17 bulletPoints.clear()18 }1920 // Handle the event of receiving the Markdown bullets list in the response stream21 onBullet { bulletText ->22 bulletPoints.add(bulletText)23 }2425 // Handle the end of the response stream26 onFinishStream {27 // Emit the last book, if present28 if (currentBookTitle.isNotEmpty() && bulletPoints.isNotEmpty()) {29 val author = bulletPoints.getOrNull(0) ?: ""30 val description = bulletPoints.getOrNull(1) ?: ""31 emit(Book(currentBookTitle, author, description))32 }33 }34 }.parseStream(markdownStream.filterTextOnly())35 }36}Java
1// TODO not yet supported in Java4. 에이전트 전략에서 파서 사용하기
Kotlin
1val agentStrategy = strategy<String, List<Book>>("library-assistant") {2 // Describe the node containing the output stream parsing3 val getMdOutput by node<String, List<Book>> { booksDescription ->4 val books = mutableListOf<Book>()5 val mdDefinition = markdownBookDefinition()67 llm.writeSession {8 appendPrompt { user(booksDescription) }9 // Initiate the response stream in the form of the definition `mdDefinition`10 val markdownStream = requestLLMStreaming(mdDefinition)11 // Call the parser with the result of the response stream and perform actions with the result12 parseMarkdownStreamToBooks(markdownStream).collect { book ->13 books.add(book)14 println("Parsed Book: ${book.title} by ${book.author}")15 }16 }1718 books19 } // Describe the agent's graph making sure the node is accessible20 edge(nodeStart forwardTo getMdOutput)21 edge(getMdOutput forwardTo nodeFinish)22}Java
1// TODO not yet supported in Java고급 사용법: 도구와 함께 스트리밍 사용하기
Streaming API를 도구와 함께 사용해 데이터가 도착하는 즉시 처리할 수도 있습니다. 다음 섹션에서는 도구를 정의하고 스트리밍 데이터와 함께 사용하는 방법을 간단히 단계별로 안내합니다.
1. 데이터 구조에 맞는 도구 정의하기
Kotlin
1@Serializable2data class Book(3 val title: String,4 val author: String,5 val description: String6)78class BookTool(): SimpleTool<Book>(9 argsType = typeToken<Book>(),10 name = NAME,11 description = "A tool to parse book information from Markdown"12) {1314 companion object { const val NAME = "book" }1516 override suspend fun execute(args: Book): String {17 println("${args.title} by ${args.author}:\n ${args.description}")18 return "Done"19 }20}Java
1class BookTool implements ToolSet {2 @Tool3 @LLMDescription("A tool to parse book information from Markdown")4 public String book(5 @LLMDescription("Title of the book") String title,6 @LLMDescription("Author of the book") String author,7 @LLMDescription("Description of the book") String description8 ) {9 System.out.println(title + " by " + author + ":\n " + description);10 return "Done";11 }12}2. 스트리밍 데이터와 함께 도구 사용하기
Kotlin
1val agentStrategy = strategy<String, Unit>("library-assistant") {2 val getMdOutput by node<String, Unit> { input ->3 val mdDefinition = markdownBookDefinition()45 llm.writeSession {6 appendPrompt { user(input) }7 val markdownStream = requestLLMStreaming(mdDefinition)89 parseMarkdownStreamToBooks(markdownStream).collect { book ->10 callToolRaw(BookTool.NAME, book)11 /* Other possible options:12 callTool(BookTool::class, book)13 callTool<BookTool>(book)14 findTool(BookTool::class).execute(book)15 */16 }1718 // We can make parallel tool calls19 parseMarkdownStreamToBooks(markdownStream).toParallelToolCallsRaw(toolClass=BookTool::class).collect {20 println("Tool call result: $it")21 }22 }23 }2425 edge(nodeStart forwardTo getMdOutput)26 edge(getMdOutput forwardTo nodeFinish)27 }Java
1var strategy = AIAgentGraphStrategy.builder("library-assistant")2 .withInput(String.class)3 .withOutput(Void.class);45var getMdOutput = AIAgentNode.builder("getMdOutput")6 .withInput(String.class)7 .withOutput(Void.class)8 .withAction((input, ctx) -> {9 StructureDefinition mdDefinition = markdownBookDefinition();1011 ctx.getLlm().writeSession(session -> {12 session.appendPrompt(prompt -> {13 prompt.user(input);14 return null;15 });1617 Flow.Publisher<StreamFrame> markdownStream = session.requestLLMStreaming(mdDefinition);1819 // Process streamed frames and invoke tools on ToolCallComplete frames20 markdownStream.subscribe(new Flow.Subscriber<StreamFrame>() {21 @Override22 public void onSubscribe(Flow.Subscription subscription) {23 subscription.request(Long.MAX_VALUE);24 }2526 @Override27 public void onNext(StreamFrame frame) {28 if (frame instanceof StreamFrame.ToolCallComplete toolCall) {29 System.out.println("Tool call: " + toolCall.getName()30 + " args=" + toolCall.getContent());31 }32 }3334 @Override35 public void onError(Throwable throwable) { }3637 @Override38 public void onComplete() { }39 });4041 return null;42 });4344 return null;45 })46 .build();4748strategy.edge(strategy.nodeStart, getMdOutput);49strategy.edge(getMdOutput, strategy.nodeFinish);3. 에이전트 설정에 도구 등록하기
Kotlin
1val toolRegistry = ToolRegistry {2 tool(BookTool())3}45val runner = AIAgent(6 promptExecutor = simpleOpenAIExecutor("OPENAI_API_KEY"),7 llmModel = OpenAIModels.Chat.GPT4o,8 toolRegistry = toolRegistry9)Java
1ToolRegistry toolRegistry = ToolRegistry.builder()2 .tools(new BookTool())3 .build();45AIAgent<String, String> runner = AIAgent.<String, String>builder()6 .promptExecutor(PromptExecutor.builder().openAI("OPENAI_API_KEY").build())7 .llmModel(OpenAIModels.Chat.GPT4o)8 .toolRegistry(toolRegistry)9 .build();모범 사례
명확한 구조 정의하기: 데이터에 대해 명확하고 모호하지 않은 markdown 구조를 만드세요.
좋은 예시 제공하기: LLM을 안내할 수 있도록
MarkdownStructureDefinition에 포괄적인 예시를 포함하세요.불완전한 데이터 처리하기: 스트림에서 데이터를 파싱할 때는 항상 null 또는 빈 값을 확인하세요.
리소스 정리하기:
onFinishStream핸들러를 사용해 리소스를 정리하고 남아 있는 데이터를 처리하세요.오류 처리하기: 잘못된 형식의 Markdown이나 예상치 못한 데이터에 대해 적절한 오류 처리를 구현하세요.
테스트하기: 부분 청크와 잘못된 입력을 포함한 다양한 입력 시나리오로 파서를 테스트하세요.
병렬 처리하기: 독립적인 데이터 항목의 경우 성능 향상을 위해 병렬 도구 호출 사용을 고려하세요.