Skip to main content

BLOGE 0.9.8-RC1 在线版 · 事实校验 2026-09-15 · English

附录 E —— 智能体扩展

第 17 章讲的是怎么在 bloge 上"思考"智能体。这份附录是你写真实智能体时一直 摆在旁边的查找表:每一个 DSL 关键字、每一条记录字段、每一个 SPI 回调、每 一项默认值、每一种运行时可能写出的 finish-reason 字符串。

什么时候翻这份附录

问题章节
agent { … } 里能用哪些 DSL 关键字?DSL 关键字索引
一个 tool { … } 块怎么变成 LLM 的工具 schema?工具块参考
有哪些记忆策略,开销分别是多少?记忆策略
exit_condition 表达式里可以引用哪些上下文变量?退出条件
AgentOutput.finishReason 实际上会是什么值?Finish Reason 与异常
监听器回调按什么顺序触发?AgentExecutionListener 速查
不用 DSL,怎么用 Java 构建智能体?Java Builder API
我得满足哪些聊天模型 SPI?LLM Provider 表面
这个模块进入 classpath 后 Spring 会自动装哪些 Bean?Spring 装配
什么时候该用 agent,什么时候用 state machine 或 session?选型表

这是 第 17 章 —— 智能体编排 的参考层;那一章给的是概念图与走通流程。

模块表面

属性取值
Maven 坐标com.leanowtech.bloge:bloge-agent-ext:${version}
DSL 扩展 kind"agent"
注册方式AgentExtensionProvider,通过 DslExtensionProvider SPI 发现
硬依赖bloge-corebloge-dsl
运行依赖bloge-common-operators(提供 LlmProvider / llmChat / llmStreamingChat / TokenEstimator);可选 JsonCodec 以反序列化工具参数

当 JAR 进入 classpath,DSL 编译器会自动识别 agent 块语法,编译期无需手工 接线。运行时则要求 OperatorRegistry 已经注册了 LLM 聊天算子(同步: "llmChat";流式:"llmStreamingChat")。

ReAct 推理循环

下面这张图把"循环 + 监听器回调点 + 流式 chunk 类型"画在一张图里:

Diagram: appendix-e-agent-ext figure 1

DSL 关键字索引

关键字作用域类型 / 取值默认值用途
agentroot、graph、phase、state 体内块前缀声明一个智能体扩展块。
stream agent同上块前缀agent 的简写,等价于 streaming = true
modelagent 体String必填传给 llmChat 的模型标识。
system_promptagent 体Stringnull每次 LLM 调用都会前置的 system 消息。
max_turnsagent 体int10think–act–observe 的硬上限。超过抛 MaxTurnsExceededException
max_tool_concurrencyagent 体Integernull(不限)单轮工具并发派发上限。
temperatureagent 体Doublenull(用 provider 默认)传给 llmChat 的采样温度。
memoryagent 体策略表达式full()对话记忆策略 —— 见下表。
exit_conditionagent 体DSL 表达式null(永不提前退出)每轮工具后求值,真值则退出循环。
streamingagent 体booleanfalse物化成 StreamingAgentLoopOperator 而不是 AgentLoopOperator
streaming_buffer_capacityagent 体Integer16流式变体内部 LLM token 缓冲区大小。
tool <name> : <OperatorRef>agent 体,可重复声明一个工具,引用的算子就是其实现。
descriptiontool 体String""传给 LLM 的工具描述。
input { … }tool 体赋值块把工具参数映射到算子输入。
tool_argsinput { … }上下文变量LLM 在本次工具调用中给出的参数对象。

记忆策略

memory = … 接受以下几种写法(都对应 AgentMemoryStrategy):

DSL 写法行为备注
full()保留出现过的所有消息。默认;CPU 最便宜,token 最贵。
sliding_window(n)保留所有 system 消息加上最近 n 条非 system 消息。n 必须 ≥ 1。
token_budget(n)从最老的非 system 消息开始裁剪,直到 TokenEstimator 报告总 token 数 ≤ n需要 TokenEstimator(已作为 SPI 注册;模块也带一个启发式实现)。
summary(n)完整保留最近 n 条非 system 消息,更早的用一段 LLM 生成的摘要替代。摘要本身要再发一次 LLM 调用,记得评估代价。
AgentMemoryStrategy.Custom(factory)仅 Java:传自己的 ConversationMemory.FactoryDSL 不可表达。

退出条件

exit_condition 是一个 DSL 表达式,每轮工具结束后基于以下上下文求值:

变量类型含义
finish_reasonString最近一次 LLM 响应的 finish reason。
contentString最近一次 LLM 响应的 assistant 内容字符串。
turnint刚结束这一轮的 1 起始编号。
tool_callsList<Map>本轮 LLM 产生的工具调用,每项包含 idnamearguments
tool_resultsMap<String,Object>当前工具批次的结果,按工具名为 key。
messagesList<LlmMessage>完整对话状态(含刚刚追加的工具消息)。

求值结果为真时,listener 的 LoopExitedPayload.exitReason"exit_condition",循环不再调用模型;正常返回的 AgentOutput.finishReason 仍保留最近一次 provider reason。

工具块参考

一个 tool 块会编译成一份 AgentToolRef。从中流出的有三样东西:

  1. 面向 LLM 的 ToolDefinition —— name(DSL 标签)、description (你写的 description = …)、由 input { … } 推导出的 JSON-schema parameters 对象。
  2. 一个子图,把被引用的算子包起来,让 agent 循环可以像普通节点一样 调用它。
  3. resultNodeId,循环用它从子图里取出该工具的输出。

工具 schema 推导

编译器会遍历 input { … } 中的每一条赋值。只有 形如 tool_args.<field> 的路径会被推到 JSON-schema 里,作为必填参数;其它 赋值(常量、引用外层 ctx 等)不出现在 schema 中,会在派发时填入。

tool searchKnowledgeBase : KBSearchOperator {
description = "Search the knowledge base"
input {
query = tool_args.query
locale = ctx.user.locale // 不出现在 LLM 看到的 schema 里
}
}

LLM 看到的是一个名为 searchKnowledgeBase 的工具,要求一个必填字符串参数 querylocale 是从外层图上下文填进去的。如果你的工具参数包含嵌套对象, 要给 DSL 编译器配置一个 JsonCodec,让 arguments JSON 可以反序列化成结构 化值。

一个完整示例

最短的端到端智能体 —— DSL 加运行所需的 Java 引导代码。

agent customerSupport {
model = "gpt-4o"
system_prompt = "You are a helpful support agent."
max_turns = 15
temperature = 0.7
memory = sliding_window(20)

tool searchKnowledgeBase : KBSearchOperator {
description = "Search the knowledge base"
input { query = tool_args.query }
}

tool escalateToHuman : EscalateOperator {
description = "Escalate to a human agent"
input { reason = tool_args.reason }
}

exit_condition = finish_reason == "stop" || tool_call("escalateToHuman")
}
OperatorRegistry registry = OperatorRegistry.builder()
.register("KBSearchOperator", new KBSearchOperator())
.register("EscalateOperator", new EscalateOperator())
.register("llmChat", new LlmChatOperator(myLlmProvider))
.build();

AgentDef def = new AgentDslCompiler(registry).compile(dslSource);
AgentLoopOperator agent = new AgentLoopOperator("supportAgent", def, registry);

AgentOutput result = agent.execute("My account is locked", OperatorContext.root());
System.out.println(result.content());
System.out.println(result.finishReason()); // provider reason,例如 "stop" / "tool_calls"

流式变体在 DSL 里写 streaming = true,或在 AgentBuilder 上调 toStreamingOperator(...);运行时通过 NodeChannel 推出 AgentStreamChunk

Java Builder API

AgentBuilder 与 DSL 关键字一一对应:

方法用途
AgentBuilder.create(id)开一个全新的 builder。
AgentBuilder.from(def)从已有的 AgentDef 起手。
.model(String)设置 LLM 模型 id。
.systemPrompt(String)设置 system prompt。
.maxTurns(int)设置轮次上限。
.maxToolConcurrency(int)设置单轮工具并发上限。
.temperature(Double)设置采样温度。
.memory(AgentMemoryStrategy)选择记忆策略。
.tool(AgentToolRef)追加一个工具。
.exitCondition(Expression | String)编译并附加退出条件表达式。
.streaming(boolean)切换到流式变体。
.streamingBufferCapacity(int)设置流式 buffer 大小。
.build()产出不可变的 AgentDef
.toOperator(ref [, registry])物化成 AgentLoopOperator
.toStreamingOperator(ref [, registry])物化成 StreamingAgentLoopOperator

记录结构

所有 record 不可变;字段按声明顺序列出。

AgentDef

字段类型备注
idStringDSL 中的标识符。
modelStringLLM 模型 id;必填。
systemPromptString可以为 null
maxTurnsint默认 10
maxToolConcurrencyIntegernull => 不限并发。
temperatureDoublenull => 使用 provider 默认。
memoryStrategyAgentMemoryStrategy默认 Full
toolsList<AgentToolRef>保持 DSL 顺序。
exitConditionExpression可以为 null
streamingbooleantrue => 使用流式算子。
streamingBufferCapacityInteger流式 buffer 大小,默认 16

AgentInput

字段类型备注
messagesList<LlmMessage>已规范化的形式;AgentInput.from(Object) 会把字符串、单条消息、列表都转过来。

AgentOutput

字段类型备注
contentString最终 assistant 内容。
finishReasonStringFinish Reason 与异常
turnsUsedint循环实际跑了多少轮。
messagesList<LlmMessage>完整对话,包括 system / assistant / tool 消息。
toolResultsMap<String,Object>最近一轮工具结果,按 tool-call id 为 key。
checkpointAgentCheckpoint用于恢复的快照;不会为 null

AgentCheckpoint

字段类型备注
conversationList<LlmMessage>截至快照点的消息。
turnCountint快照时的轮次。
toolResultsMap<String,Object>快照时的工具结果。

把它与你自己的 session / state-machine checkpoint 一起持久化,就可以在 重启后恢复一次智能体运行。

AgentToolRef

字段类型备注
nameString暴露给 LLM 的工具名。
operatorRefStringOperatorRegistry 中注册的算子名。
descriptionStringLLM 看到的同一份描述。
graphGraph包装该算子的编译期子图。
resultNodeIdString输出作为工具结果的节点。
toolDefinitionLlmProvider.ToolDefinitionLLM 侧 schema(name + description + parameters)。

AgentStreamChunk(sealed)

变体字段触发时机
Tokentext: StringturnNumber: int每个 LLM token(或一组)。
ToolStarttoolName: StringtoolCallId: StringturnNumber: int工具派发之前。
ToolEndtoolName: StringtoolCallId: Stringsuccess: booleanturnNumber: int工具完成之后。
DonefinalOutput: AgentOutput流的最后一个 chunk,循环已退出。

Finish Reason 与异常

表面取值与出现时机
AgentOutput.finishReason最近一次 provider reason,例如 "stop""length""tool_calls";原样保留。
LoopExitedPayload.exitReason"stop""exit_condition""max_turns",说明 loop 为什么结束。
MaxTurnsExceededException达到 max_turns 时直接抛出,携带 checkpoint;不会返回 finishReason 为 "max_turns" 的正常 output。

MaxTurnsExceededException 是从 AgentLoopOperator.execute 抛出来的; 调用方代码负责捕获它并向上层报出有意义的错误。

AgentExecutionListener 速查

回调载荷 record字段
onAgentTurnStartedTurnStartedPayloadturnNumbermessageCount
onAgentLlmCalledLlmCalledPayloadturnNumbermodelpromptTokenscompletionTokenslatencyMsfinishReason
onAgentToolDispatchedToolDispatchedPayloadturnNumbertoolCallIdtoolNameargumentsJson
onAgentToolCompletedToolCompletedPayloadturnNumbertoolCallIdtoolNamesuccesslatencyMsresultSummary
onAgentLoopExitedLoopExitedPayloadtotalTurnstotalLlmCallstotalLlmTokenstotalLatencyMsexitReason

每个回调都会拿到一个 AgentCallbackContext,里面有 executionIdgraphNamenodeIdoperatorRef。所有回调都有默认空实现,只覆写你关心 的那几个就行。

典型一轮的顺序是:onAgentTurnStartedonAgentLlmCalled →(每次工具调用) onAgentToolDispatched →(每次工具调用)onAgentToolCompleted → 进入下一轮 或者 onAgentLoopExited

ConversationMemory SPI

ConversationMemory 是 sealed 接口(实现有 FullConversationMemorySlidingWindowConversationMemoryTokenBudgetConversationMemorySummaryConversationMemory)。用静态工厂方法构造实例:

工厂备注
ConversationMemory.full(initialMessages)不裁剪。
ConversationMemory.slidingWindow(initialMessages, windowSize)保留所有 system 消息加上最近 windowSize 条非 system 消息。
ConversationMemory.tokenBudget(initialMessages, maxTokens, tokenEstimator)从最老的非 system 消息开始裁剪直到 token 总数符合预算。
ConversationMemory.summary(initialMessages, summarizer, summaryThreshold)当非 system 消息条数超过 summaryThreshold 时调用 summarizer

两个函数式 SPI 让你接入自定义行为:

  • ConversationMemory.Factory —— create(List<LlmMessage> initial),可以 返回任何 ConversationMemory 实例,是 AgentMemoryStrategy.Custom(factory) 的钩子。
  • ConversationMemory.Summarizer —— summarize(List<LlmMessage>),是 Summary 策略所调用的函数。bloge 运行时不内置默认 summarizer;自己接一个 即可,通常就是再发一次 llmChat,prompt 写"请总结对话"。

LLM Provider 表面(bloge-common-operators

agent 循环从来不直接和 OpenAI / Anthropic / Azure 通信。它只调用 OperatorRegistry 里按标准名注册的算子。这套契约住在 bloge-common-operators 模块:

类型FQN用途
LlmProvidercom.leanowtech.bloge.operators.spi.LlmProvider底层聊天模型客户端的 SPI。
LlmProvider.LlmMessage嵌套 recordrolecontentpartstoolCallstoolCallId
LlmProvider.ToolDefinition嵌套 recordnamedescriptionparameters(JSON-schema map)。
LlmChatOperatorcom.leanowtech.bloge.operators.ai.LlmChatOperator同步聊天算子;默认注册名 "llmChat"
流式聊天算子同模块注册名为 "llmStreamingChat",被流式 agent 使用。
TokenEstimatorcom.leanowtech.bloge.operators.spi.TokenEstimator估算消息 token 数,给 token_budget 记忆用。模块自带启发式默认实现。

新写一个 provider 就实现 LlmProvider,注册算子时传给 LlmChatOperator。 agent 循环对此完全透明。

Spring 装配

bloge-agent-ext 自己不带任何 Spring 自动配置。在 bloge-spring 里有两个 条件钩子覆盖了 Spring 使用者需要的一切:

Bean定义位置触发条件
AgentEventJournalBridgeBlogeEventJournalAutoConfigurationbloge-agent-extbloge-event-journal 同时在 classpath,且 spring.bloge.event-journal.enabled=true 时注册;把所有 AgentExecutionListener 回调注入引擎的事件日志。
可选 listener 装配OptionalAgentListenerSupport软加载 AgentExecutionListener,没有 agent-ext JAR 时 bloge-spring 其它部分仍能工作。容器里任何 AgentExecutionListener Bean 都会被自动拾取。

只有一条属性:

spring.bloge:
event-journal:
enabled: true

除此之外,把你的 provider、算子、监听器作为普通 Spring Bean 暴露即可 —— 都会沿第 20 章描述的 OperatorRegistryAutoConfiguration 路径被拾取。

选型表

agent 循环和其它 bloge 概念在工程里怎么分工?

问题选什么
是否有一条按固定顺序跑的算子流水线?普通 bloge 图,不用 agent。
是否需要 LLM 来决定调哪些算子、按什么顺序、参数由它合成?agent { … }
是否要按 phase / 守卫驱动一个确定性工作流?状态机(附录 D)。
是否要崩溃安全的多轮用户会话(不是 LLM 自己选工具)?Session(第 14 章)。
是否要把 token 流向前端 UI?流式变体 —— streaming = truestream agent;流式整体模型见附录 C。
是否要细粒度追踪 LLM 调用、工具派发与耗时?AgentExecutionListener + AgentEventJournalBridge

agent 与状态机可以叠在一起:agent 可以当某一个 state 的 operator,状态机 也可以把若干"专家 agent"串起来。

常见错误

  • 忘写 model = "…" 编译会失败。model id 必填,会原样传给聊天算子。
  • 没注册 llmChat 算子。 DSL 编译通过,但第一轮就报"unknown operator"。 搭 registry 时记得接 LlmChatOperator(或流式 llmStreamingChat)。
  • input { … } 之外用 tool_args.x tool_args 只在某个 tool 的 input 块里有效;在 exit_condition 或别处用都是编译错。
  • 长对话上用了 full() 每轮都把整段历史发回去 —— 上生产前换成 sliding_window(n)token_budget(n)
  • summary() 没接 summarizer。 默认 Summary 策略需要外部摘要回调 (通常是一次 LLM 调用);没接的话会退化成"只保留阈值窗口"。
  • 永远为假的 exit_condition 如果引用了根本没人产生的 tool 结果 key, 循环每次都会跑到 max_turns。跑测试时要同时验证 happy path 与封顶路径。
  • 嵌套对象工具参数 schema 不匹配。 工具参数是结构化值时,给 DSL 编译器 传一个 JsonCodec,这样 tool_args.foo.bar 才能解析到反序列化后的对象; 否则只能干净地走顶层字符串字段。
  • MaxTurnsExceededException 在边界没被捕获。 异常会从 execute() 往上 传;调用 agent 的算子代码必须 catch 它并报出有意义的失败,否则外层图会 把节点标成 crashed。

与正文章节的关系

  • 第 7 章 —— 设计良好的算子。 算子元数据字段(promptHintusageExampleconstraintsDescription)正是 agent 循环用来填充工具 description 与 JSON-schema 提示的来源。
  • 第 11 章 —— 批处理与迭代附录 C —— 流式。 流式变体通过附录 C 说明的同一套 NodeChannel 推出 AgentStreamChunk
  • 第 19 章 —— 生产可观测性。 AgentEventJournalBridge 把每一个回调 接入第 19 章描述的事件日志。
  • 第 9 章 —— 工具与工作流。 operator-metadata.json 导出器输出的结构 与 agent 循环发给模型的 ToolDefinition 同形。
  • 第 20 章 —— Spring 与生产装配。 上面说的条件 Bean 注册; OperatorRegistryAutoConfiguration 是暴露算子与监听器的标准路径。
  • 第 17 章 —— 智能体编排。 本附录就是它的参考层。