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-core、bloge-dsl |
| 运行依赖 | bloge-common-operators(提供 LlmProvider / llmChat / llmStreamingChat / TokenEstimator);可选 JsonCodec 以反序列化工具参数 |
当 JAR 进入 classpath,DSL 编译器会自动识别 agent 块语法,编译期无需手工
接线。运行时则要求 OperatorRegistry 已经注册了 LLM 聊天算子(同步:
"llmChat";流式:"llmStreamingChat")。
ReAct 推理循环
下面这张图把"循环 + 监听器回调点 + 流式 chunk 类型"画在一张图里:
DSL 关键字索引
| 关键字 | 作用域 | 类型 / 取值 | 默认值 | 用途 |
|---|---|---|---|---|
agent | root、graph、phase、state 体内 | 块前缀 | — | 声明一个智能体扩展块。 |
stream agent | 同上 | 块前缀 | — | agent 的简写,等价于 streaming = true。 |
model | agent 体 | String | 必填 | 传给 llmChat 的模型标识。 |
system_prompt | agent 体 | String | null | 每次 LLM 调用都会前置的 system 消息。 |
max_turns | agent 体 | int | 10 | think–act–observe 的硬上限。超过抛 MaxTurnsExceededException。 |
max_tool_concurrency | agent 体 | Integer | null(不限) | 单轮工具并发派发上限。 |
temperature | agent 体 | Double | null(用 provider 默认) | 传给 llmChat 的采样温度。 |
memory | agent 体 | 策略表达式 | full() | 对话记忆策略 —— 见下表。 |
exit_condition | agent 体 | DSL 表达式 | null(永不提前退出) | 每轮工具后求值,真值则退出循环。 |
streaming | agent 体 | boolean | false | 物化成 StreamingAgentLoopOperator 而不是 AgentLoopOperator。 |
streaming_buffer_capacity | agent 体 | Integer | 16 | 流式变体内部 LLM token 缓冲区大小。 |
tool <name> : <OperatorRef> | agent 体,可重复 | 块 | — | 声明一个工具,引用的算子就是其实现。 |
description | tool 体 | String | "" | 传给 LLM 的工具描述。 |
input { … } | tool 体 | 赋值块 | — | 把工具参数映射到算子输入。 |
tool_args | input { … } 内 | 上下文变量 | — | 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.Factory。 | DSL 不可表达。 |
退出条件
exit_condition 是一个 DSL 表达式,每轮工具结束后基于以下上下文求值:
| 变量 | 类型 | 含义 |
|---|---|---|
finish_reason | String | 最近一次 LLM 响应的 finish reason。 |
content | String | 最近一次 LLM 响应的 assistant 内容字符串。 |
turn | int | 刚结束这一轮的 1 起始编号。 |
tool_calls | List<Map> | 本轮 LLM 产生的工具调用,每项包含 id、name、arguments。 |
tool_results | Map<String,Object> | 当前工具批次的结果,按工具名为 key。 |
messages | List<LlmMessage> | 完整对话状态(含刚刚追加的工具消息)。 |
求值结果为真时,listener 的 LoopExitedPayload.exitReason 为 "exit_condition",循环不再调用模型;正常返回的 AgentOutput.finishReason 仍保留最近一次 provider reason。
工具块参考
一个 tool 块会编译成一份 AgentToolRef。从中流出的有三样东西:
- 面向 LLM 的
ToolDefinition——name(DSL 标签)、description(你写的description = …)、由input { … }推导出的 JSON-schemaparameters对象。 - 一个子图,把被引用的算子包起来,让 agent 循环可以像普通节点一样 调用它。
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 的工具,要求一个必填字符串参数
query;locale 是从外层图上下文填进去的。如果你的工具参数包含嵌套对象,
要给 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
| 字段 | 类型 | 备注 |
|---|---|---|
id | String | DSL 中的标识符。 |
model | String | LLM 模型 id;必填。 |
systemPrompt | String | 可以为 null。 |
maxTurns | int | 默认 10。 |
maxToolConcurrency | Integer | null => 不限并发。 |
temperature | Double | null => 使用 provider 默认。 |
memoryStrategy | AgentMemoryStrategy | 默认 Full。 |
tools | List<AgentToolRef> | 保持 DSL 顺序。 |
exitCondition | Expression | 可以为 null。 |
streaming | boolean | true => 使用流式算子。 |
streamingBufferCapacity | Integer | 流式 buffer 大小,默认 16。 |
AgentInput
| 字段 | 类型 | 备注 |
|---|---|---|
messages | List<LlmMessage> | 已规范化的形式;AgentInput.from(Object) 会把字符串、单条消息、列表都转过来。 |
AgentOutput
| 字段 | 类型 | 备注 |
|---|---|---|
content | String | 最终 assistant 内容。 |
finishReason | String | 见 Finish Reason 与异常。 |
turnsUsed | int | 循环实际跑了多少轮。 |
messages | List<LlmMessage> | 完整对话,包括 system / assistant / tool 消息。 |
toolResults | Map<String,Object> | 最近一轮工具结果,按 tool-call id 为 key。 |
checkpoint | AgentCheckpoint | 用于恢复的快照;不会为 null。 |
AgentCheckpoint
| 字段 | 类型 | 备注 |
|---|---|---|
conversation | List<LlmMessage> | 截至快照点的消息。 |
turnCount | int | 快照时的轮次。 |
toolResults | Map<String,Object> | 快照时的工具结果。 |
把它与你自己的 session / state-machine checkpoint 一起持久化,就可以在 重启后恢复一次智能体运行。
AgentToolRef
| 字段 | 类型 | 备注 |
|---|---|---|
name | String | 暴露给 LLM 的工具名。 |
operatorRef | String | OperatorRegistry 中注册的算子名。 |
description | String | LLM 看到的同一份描述。 |
graph | Graph | 包装该算子的编译期子图。 |
resultNodeId | String | 输出作为工具结果的节点。 |
toolDefinition | LlmProvider.ToolDefinition | LLM 侧 schema(name + description + parameters)。 |
AgentStreamChunk(sealed)
| 变体 | 字段 | 触发时机 |
|---|---|---|
Token | text: String、turnNumber: int | 每个 LLM token(或一组)。 |
ToolStart | toolName: String、toolCallId: String、turnNumber: int | 工具派发之前。 |
ToolEnd | toolName: String、toolCallId: String、success: boolean、turnNumber: int | 工具完成之后。 |
Done | finalOutput: 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 | 字段 |
|---|---|---|
onAgentTurnStarted | TurnStartedPayload | turnNumber、messageCount |
onAgentLlmCalled | LlmCalledPayload | turnNumber、model、promptTokens、completionTokens、latencyMs、finishReason |
onAgentToolDispatched | ToolDispatchedPayload | turnNumber、toolCallId、toolName、argumentsJson |
onAgentToolCompleted | ToolCompletedPayload | turnNumber、toolCallId、toolName、success、latencyMs、resultSummary |
onAgentLoopExited | LoopExitedPayload | totalTurns、totalLlmCalls、totalLlmTokens、totalLatencyMs、exitReason |
每个回调都会拿到一个 AgentCallbackContext,里面有 executionId、
graphName、nodeId、operatorRef。所有回调都有默认空实现,只覆写你关心
的那几个就行。
典型一轮的顺序是:onAgentTurnStarted → onAgentLlmCalled →(每次工具调用)
onAgentToolDispatched →(每次工具调用)onAgentToolCompleted → 进入下一轮
或者 onAgentLoopExited。
ConversationMemory SPI
ConversationMemory 是 sealed 接口(实现有 FullConversationMemory、
SlidingWindowConversationMemory、TokenBudgetConversationMemory、
SummaryConversationMemory)。用静态工厂方法构造实例:
| 工厂 | 备注 |
|---|---|
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 | 用途 |
|---|---|---|
LlmProvider | com.leanowtech.bloge.operators.spi.LlmProvider | 底层聊天模型客户端的 SPI。 |
LlmProvider.LlmMessage | 嵌套 record | role、content、parts、toolCalls、toolCallId。 |
LlmProvider.ToolDefinition | 嵌套 record | name、description、parameters(JSON-schema map)。 |
LlmChatOperator | com.leanowtech.bloge.operators.ai.LlmChatOperator | 同步聊天算子;默认注册名 "llmChat"。 |
| 流式聊天算子 | 同模块 | 注册名为 "llmStreamingChat",被流式 agent 使用。 |
TokenEstimator | com.leanowtech.bloge.operators.spi.TokenEstimator | 估算消息 token 数,给 token_budget 记忆用。模块自带启发式默认实现。 |
新写一个 provider 就实现 LlmProvider,注册算子时传给 LlmChatOperator。
agent 循环对此完全透明。
Spring 装配
bloge-agent-ext 自己不带任何 Spring 自动配置。在 bloge-spring 里有两个
条件钩子覆盖了 Spring 使用者需要的一切:
| Bean | 定义位置 | 触发条件 |
|---|---|---|
AgentEventJournalBridge | BlogeEventJournalAutoConfiguration | 当 bloge-agent-ext、bloge-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 = true 或 stream 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 章 —— 设计良好的算子。 算子元数据字段(
promptHint、usageExample、constraintsDescription)正是 agent 循环用来填充工具description与 JSON-schema 提示的来源。 - 第 11 章 —— 批处理与迭代 与 附录 C —— 流式。 流式变体通过附录 C
说明的同一套
NodeChannel推出AgentStreamChunk。 - 第 19 章 —— 生产可观测性。
AgentEventJournalBridge把每一个回调 接入第 19 章描述的事件日志。 - 第 9 章 —— 工具与工作流。
operator-metadata.json导出器输出的结构 与 agent 循环发给模型的ToolDefinition同形。 - 第 20 章 —— Spring 与生产装配。 上面说的条件 Bean 注册;
OperatorRegistryAutoConfiguration是暴露算子与监听器的标准路径。 - 第 17 章 —— 智能体编排。 本附录就是它的参考层。