跳到主要内容

Agent 与 Agentic AI

备注

本节介绍如何使用 langchain4j-agentic 模块构建 Agentic AI 应用。请注意,整个模块均属于实验性功能,未来版本可能会发生变化。

Agentic 系统

尽管 AI Agent 尚无全球一致认可的定义,但一些新兴模式展示了如何协调并组合多个 AI 服务的能力,从而创建可完成更复杂任务的 AI 增强型应用。这些模式通常被称为“Agentic 系统”或“Agentic AI”。它们通常使用大语言模型(LLM)来编排任务执行、管理工具使用,并在交互过程中维护上下文。

根据 Anthropic 研究人员近期发表的文章,这些 Agentic 系统架构可归为两大类:工作流和纯 Agent。

本教程讨论的 langchain4j-agentic 模块提供了一组抽象和工具,帮助你构建工作流和纯 Agentic AI 应用。它支持定义工作流、管理工具使用,以及在与不同 LLM 的交互中维护上下文。

LangChain4j 中的 Agent

LangChain4j 中的 Agent 使用 LLM 执行一项或一组特定任务。与普通 AI 服务类似,可以通过一个只有单个方法的接口来定义 Agent,只需添加 @Agent 注解。

public interface CreativeWriter {

@UserMessage("""
You are a creative writer.
Generate a draft of a story no more than
3 sentences long around the given topic.
Return only the story and nothing else.
The topic is {{topic}}.
""")
@Agent("Generates a story based on the given topic")
String generateStory(@V("topic") String topic);
}

建议同时在该注解中提供对 Agent 用途的简短描述,尤其是在纯 Agentic 模式中使用时;其他 Agent 需要了解此 Agent 的能力,才能明智地决定如何以及何时使用它。构建 Agent 时,也可以通过 Agent builder 的 description 方法以编程方式提供此描述。

Agent 还必须拥有一个在 Agentic 系统内唯一标识它的名称。该名称可以在 @Agent 注解中指定,或通过 Agent builder 的 name 方法以编程方式指定。未指定时,名称取自标注 @Agent 的方法名。

现在可以使用 AgenticServices.agentBuilder() 方法构建此 Agent 的实例,并指定接口及要使用的聊天模型。

CreativeWriter creativeWriter = AgenticServices
.agentBuilder(CreativeWriter.class)
.chatModel(myChatModel)
.outputKey("story")
.build();

从本质上讲,Agent 就是普通 AI 服务,具备相同功能,但能够与其他 Agent 组合,以创建更复杂的工作流和 Agentic 系统。

与 AI 服务的另一主要差异是 outputKey 参数:它指定用于存储 Agent 调用结果的共享变量名称,使同一 Agentic 系统中的其他 Agent 可以使用该结果。或者,也可以直接在 @Agent 注解中声明输出名称,而非像本例一样以编程方式设置,从而省略代码中的该设置并在此处添加。

@Agent(outputKey = "story", description = "Generates a story based on the given topic")

AgenticServices 类提供了一组静态工厂方法,用于创建和定义 langchain4j-agentic 框架提供的各种 Agent。

AgenticScope 简介

langchain4j-agentic 模块引入了 AgenticScope 概念,它是参与同一 Agentic 系统的 Agent 之间共享的数据集合。AgenticScope 用于存储共享变量:一个 Agent 可以写入其生成的结果以进行通信,另一个 Agent 可以读取这些变量,汇集执行任务所需的信息。这使 Agent 能够有效协作,按需共享信息和结果。

AgenticScope 还会自动记录其他相关信息,例如所有 Agent 的调用序列及其响应。在调用 Agentic 系统的主 Agent 时会自动创建它,并在必要时通过回调以编程方式提供。讨论 langchain4j-agentic 实现的 Agentic 模式时,将通过实际示例说明 AgenticScope 的不同用法。

工作流模式

langchain4j-agentic 模块提供一组抽象,用于以编程方式编排多个 Agent 并创建 Agentic 工作流模式。这些模式可以组合为更复杂的工作流。

顺序工作流

顺序工作流是最简单的模式:多个 Agent 依次被调用,每个 Agent 的输出作为下一个 Agent 的输入。当一系列任务需要按特定顺序执行时,此模式十分有用。

例如,可以为前面定义的 CreativeWriter Agent 配置 AudienceEditor Agent,以编辑生成的故事,使其更适合特定受众。

public interface AudienceEditor {

@UserMessage("""
You are a professional editor.
Analyze and rewrite the following story to better align
with the target audience of {{audience}}.
Return only the story and nothing else.
The story is "{{story}}".
""")
@Agent("Edits a story to better fit a given audience")
String editStory(@V("story") String story, @V("audience") String audience);
}

再配合一个非常相似的 StyleEditor,为特定风格执行同样的工作。

public interface StyleEditor {

@UserMessage("""
You are a professional editor.
Analyze and rewrite the following story to better fit and be more coherent with the {{style}} style.
Return only the story and nothing else.
The story is "{{story}}".
""")
@Agent("Edits a story to better fit a given style")
String editStory(@V("story") String story, @V("style") String style);
}

请注意,此 Agent 的输入参数使用变量名注解。实际上,传给 Agent 的参数值并非直接提供,而是取自具有这些名称的 AgenticScope 共享变量。这使 Agent 能够访问工作流中先前 Agent 的输出。如果编译 Agent 类时启用了 -parameters 选项,从而在运行时保留方法参数名称,则可以省略 @V 注解,变量名会从参数名自动推断。

此时可以创建一个组合这三个 Agent 的顺序工作流:CreativeWriter 的输出会传给 AudienceEditorStyleEditor 作为输入,最终输出是编辑后的故事。

CreativeWriter creativeWriter = AgenticServices
.agentBuilder(CreativeWriter.class)
.chatModel(BASE_MODEL)
.outputKey("story")
.build();

AudienceEditor audienceEditor = AgenticServices
.agentBuilder(AudienceEditor.class)
.chatModel(BASE_MODEL)
.outputKey("story")
.build();

StyleEditor styleEditor = AgenticServices
.agentBuilder(StyleEditor.class)
.chatModel(BASE_MODEL)
.outputKey("story")
.build();

UntypedAgent novelCreator = AgenticServices
.sequenceBuilder()
.subAgents(creativeWriter, audienceEditor, styleEditor)
.outputKey("story")
.build();

Map<String, Object> input = Map.of(
"topic", "dragons and wizards",
"style", "fantasy",
"audience", "young adults"
);

String story = (String) novelCreator.invoke(input);

这里的 novelCreator Agent 实际上是一个实现顺序工作流的 Agentic 系统,它依次调用三个子 Agent。由于此 Agent 的定义没有提供类型化接口,顺序 Agent builder 会返回 UntypedAgent 实例;它是可通过输入 map 调用的通用 Agent。

public interface UntypedAgent {
@Agent
Object invoke(Map<String, Object> input);
}

输入 map 中的值会复制到 AgenticScope 共享变量,以供子 Agent 访问。novelCreator Agent 的输出也取自名为“story”的 AgenticScope 共享变量;在小说创建和编辑工作流执行期间,其他所有 Agent 都曾重写此变量。

请注意,单个 Agent 也可以定义为 UntypedAgent 实例,无需提供类型化接口。例如,CreativeWriter Agent 可按如下方式定义:

UntypedAgent creativeWriter = AgenticServices.agentBuilder()
.chatModel(BASE_MODEL)
.description("Generate a story based on the given topic")
.userMessage("""
You are a creative writer.
Generate a draft of a story no more than
3 sentences long around the given topic.
Return only the story and nothing else.
The topic is {{topic}}.
""")
.inputKey(String.class, "topic")
.returnType(String.class) // String is the default return type for untyped agents
.outputKey("story")
.build();

另一方面,工作流 Agent 也可以选择提供类型化接口,从而可使用强类型的输入和输出进行调用。此时可将 UntypedAgent 接口替换为更具体的接口,例如:

public interface NovelCreator {

@Agent
String createNovel(@V("topic") String topic, @V("audience") String audience, @V("style") String style);
}

这样便可按如下方式创建和使用 novelCreator Agent:

NovelCreator novelCreator = AgenticServices
.sequenceBuilder(NovelCreator.class)
.subAgents(creativeWriter, audienceEditor, styleEditor)
.outputKey("story")
.build();

String story = novelCreator.createNovel("dragons and wizards", "young adults", "fantasy");

循环工作流

一种更好地利用 LLM 能力的常见方法是:重复调用能够编辑或改进文本的 Agent,迭代地润色故事等文本。可使用循环工作流模式实现,即重复调用 Agent,直到满足某个条件。

可以使用 StyleScorer Agent,根据风格与要求的契合程度生成评分。

public interface StyleScorer {

@UserMessage("""
You are a critical reviewer.
Give a review score between 0.0 and 1.0 for the following
story based on how well it aligns with the style '{{style}}'.
Return only the score and nothing else.

The story is: "{{story}}"
""")
@Agent("Scores a story based on how well it aligns with a given style")
double scoreStyle(@V("story") String story, @V("style") String style);
}

然后可以在循环中结合此 Agent 和 StyleEditor,迭代改进故事,直到评分达到特定阈值(如 0.8),或达到最大迭代次数。

StyleEditor styleEditor = AgenticServices
.agentBuilder(StyleEditor.class)
.chatModel(BASE_MODEL)
.outputKey("story")
.build();

StyleScorer styleScorer = AgenticServices
.agentBuilder(StyleScorer.class)
.chatModel(BASE_MODEL)
.outputKey("score")
.build();

UntypedAgent styleReviewLoop = AgenticServices
.loopBuilder()
.subAgents(styleScorer, styleEditor)
.maxIterations(5)
.exitCondition( agenticScope -> agenticScope.readState("score", 0.0) >= 0.8)
.build();

这里 styleScorer Agent 将输出写入名为“score”的 AgenticScope 共享变量;循环的退出条件会访问并评估同一变量。

exitCondition 方法接受 Predicate<AgenticScope> 参数,默认会在每次 Agent 调用后评估它;条件一经满足就退出循环,以尽量减少 Agent 调用次数。不过,也可以通过在循环 builder 上配置 testExitAtLoopEnd(true),仅在一次循环结束时检查退出条件,从而在测试该条件前强制调用所有 Agent。或者,exitCondition 还可接受 BiPredicate<AgenticScope, Integer> 参数,其中第二个参数是当前循环迭代计数器。例如,以下循环定义:

UntypedAgent styleReviewLoop = AgenticServices
.loopBuilder()
.subAgents(styleScorer, styleEditor)
.maxIterations(5)
.testExitAtLoopEnd(true)
.exitCondition( (agenticScope, loopCounter) -> {
double score = agenticScope.readState("score", 0.0);
return loopCounter <= 3 ? score >= 0.8 : score >= 0.6;
})
.build();

会使循环在前 3 次迭代中评分至少为 0.8 时退出;否则会降低质量预期,在评分至少为 0.6 时终止循环,并且即使已满足退出条件,也会强制最后调用一次 styleEditor Agent。

配置好此 styleReviewLoop 后,可将其视为单个 Agent,并与 CreativeWriter Agent 按顺序组合,创建 StyledWriter Agent。

public interface StyledWriter {

@Agent
String writeStoryWithStyle(@V("topic") String topic, @V("style") String style);
}

从而实现结合故事生成和风格审查过程的更复杂工作流。

CreativeWriter creativeWriter = AgenticServices
.agentBuilder(CreativeWriter.class)
.chatModel(BASE_MODEL)
.outputKey("story")
.build();

StyledWriter styledWriter = AgenticServices
.sequenceBuilder(StyledWriter.class)
.subAgents(creativeWriter, styleReviewLoop)
.outputKey("story")
.build();

String story = styledWriter.writeStoryWithStyle("dragons and wizards", "comedy");

并行工作流

有时并行调用多个 Agent 很有用,尤其当它们可针对同一输入独立工作时。可以使用并行工作流模式实现:同时调用多个 Agent,并将它们的输出合并为单个结果。

例如,使用电影和美食专家,为具有特定氛围的美好夜晚生成若干方案;每个方案结合与该氛围相匹配的电影和餐点。

public interface FoodExpert {

@UserMessage("""
You are a great evening planner.
Propose a list of 3 meals matching the given mood.
The mood is {{mood}}.
For each meal, just give the name of the meal.
Provide a list with the 3 items and nothing else.
""")
@Agent
List<String> findMeal(@V("mood") String mood);
}

public interface MovieExpert {

@UserMessage("""
You are a great evening planner.
Propose a list of 3 movies matching the given mood.
The mood is {mood}.
Provide a list with the 3 items and nothing else.
""")
@Agent
List<String> findMovie(@V("mood") String mood);
}

由于两位专家的工作彼此独立,可以如下使用 AgenticServices.parallelBuilder() 方法并行调用它们:

FoodExpert foodExpert = AgenticServices
.agentBuilder(FoodExpert.class)
.chatModel(BASE_MODEL)
.outputKey("meals")
.build();

MovieExpert movieExpert = AgenticServices
.agentBuilder(MovieExpert.class)
.chatModel(BASE_MODEL)
.outputKey("movies")
.build();

EveningPlannerAgent eveningPlannerAgent = AgenticServices
.parallelBuilder(EveningPlannerAgent.class)
.subAgents(foodExpert, movieExpert)
.executor(Executors.newFixedThreadPool(2))
.outputKey("plans")
.output(agenticScope -> {
List<String> movies = agenticScope.readState("movies", List.of());
List<String> meals = agenticScope.readState("meals", List.of());

List<EveningPlan> moviesAndMeals = new ArrayList<>();
for (int i = 0; i < movies.size(); i++) {
if (i >= meals.size()) {
break;
}
moviesAndMeals.add(new EveningPlan(movies.get(i), meals.get(i)));
}
return moviesAndMeals;
})
.build();

List<EveningPlan> plans = eveningPlannerAgent.plan("romantic");

这里,EveningPlannerAgent 中定义的 AgenticScope output 函数可组合两个子 Agent 的输出,创建由符合给定氛围的电影和餐点组成的 EveningPlan 对象列表。output 方法虽然对并行工作流尤为重要,但实际上可用于任何工作流模式,用于定义如何将子 Agent 的输出组合为单个结果,而非仅从 AgenticScope 返回一个值。executor 方法还允许可选地提供用于并行执行子 Agent 的 Executor;否则默认使用内部缓存线程池。

并行映射工作流

并行映射工作流是并行工作流的一种变体:对集合中的每个元素并行执行一次相同的子 Agent。换言之,它映射列表中的所有元素,重复调用同一子 Agent 独立处理每一项。当需要对一批输入并发应用相同操作时,此模式很有用。

例如,创建一个为人员列表生成个性化星座运势的 Agent:

public interface PersonAstrologyAgent {

@SystemMessage("""
You are an astrologist that generates horoscopes based on the user's name and zodiac sign.
""")
@UserMessage("""
Generate the horoscope for {{person}}.
The person has a name and a zodiac sign. Use both to create a personalized horoscope.
""")
@Agent(description = "An astrologist that generates horoscopes for a person", outputKey = "horoscope")
String horoscope(@V("person") Person person);
}

使用 AgenticServices.parallelMapperBuilder(),可以创建一个将此 Agent 扇出到集合上的工作流,自动为每一项创建一个实例:

PersonAstrologyAgent personAstrologyAgent = AgenticServices
.agentBuilder(PersonAstrologyAgent.class)
.chatModel(BASE_MODEL)
.outputKey("horoscope")
.build();

BatchHoroscopeAgent agent = AgenticServices
.parallelMapperBuilder(BatchHoroscopeAgent.class)
.subAgents(personAstrologyAgent)
.itemsProvider("persons")
.executor(Executors.newFixedThreadPool(3))
.build();

List<Person> persons = List.of(
new Person("Mario", "aries"),
new Person("Luigi", "pisces"),
new Person("Peach", "leo"));

List<String> horoscopes = agent.generateHoroscopes(persons);

其中 BatchHoroscopeAgent 定义如下:

public interface BatchHoroscopeAgent extends AgentInstance {

@Agent
List<String> generateHoroscopes(@V("persons") List<Person> persons);
}

itemsProvider 指定哪个参数包含要迭代的集合。不过,如果没有歧义且只有一个可迭代参数(Collection 或数组),如本例所示,则可以安全省略。每个子 Agent 实例接收集合中的一项;所有实例完成后,其各自结果会自动聚合为列表并作为工作流结果返回。与并行工作流一样,也可选择提供 Executor

请注意,由于同一 Agent 会以不同参数独立、重复地执行同一任务,为其持有 ChatMemory 没有意义。因此,如果并行映射工作流尝试使用配置了 ChatMemory 的子 Agent,LangChain4j 会抛出异常。

条件工作流

另一常见需求是仅在满足特定条件时调用某个 Agent。例如,可在处理用户请求前对其分类,以便根据请求类别由不同 Agent 处理。可使用以下 CategoryRouter 实现:

public interface CategoryRouter {

@UserMessage("""
Analyze the following user request and categorize it as 'legal', 'medical' or 'technical'.
In case the request doesn't belong to any of those categories categorize it as 'unknown'.
Reply with only one of those words and nothing else.
The user request is: '{{request}}'.
""")
@Agent("Categorizes a user request")
RequestCategory classify(@V("request") String request);
}

它返回一个 RequestCategory 枚举值。

public enum RequestCategory {
LEGAL, MEDICAL, TECHNICAL, UNKNOWN
}

这样,定义如下的 MedicalExpert Agent 后:

public interface MedicalExpert {

@UserMessage("""
You are a medical expert.
Analyze the following user request under a medical point of view and provide the best possible answer.
The user request is {{request}}.
""")
@Agent("A medical expert")
String medical(@V("request") String request);
}

再加上类似的 LegalExpertTechnicalExpert Agent,就可以创建 ExpertRouterAgent

public interface ExpertRouterAgent {

@Agent
String ask(@V("request") String request);
}

以实现一个根据用户请求类别调用相应 Agent 的条件工作流。

CategoryRouter routerAgent = AgenticServices
.agentBuilder(CategoryRouter.class)
.chatModel(BASE_MODEL)
.outputKey("category")
.build();

MedicalExpert medicalExpert = AgenticServices
.agentBuilder(MedicalExpert.class)
.chatModel(BASE_MODEL)
.outputKey("response")
.build();
LegalExpert legalExpert = AgenticServices
.agentBuilder(LegalExpert.class)
.chatModel(BASE_MODEL)
.outputKey("response")
.build();
TechnicalExpert technicalExpert = AgenticServices
.agentBuilder(TechnicalExpert.class)
.chatModel(BASE_MODEL)
.outputKey("response")
.build();

UntypedAgent expertsAgent = AgenticServices.conditionalBuilder()
.subAgents( agenticScope -> agenticScope.readState("category", RequestCategory.UNKNOWN) == RequestCategory.MEDICAL, medicalExpert)
.subAgents( agenticScope -> agenticScope.readState("category", RequestCategory.UNKNOWN) == RequestCategory.LEGAL, legalExpert)
.subAgents( agenticScope -> agenticScope.readState("category", RequestCategory.UNKNOWN) == RequestCategory.TECHNICAL, technicalExpert)
.build();

ExpertRouterAgent expertRouterAgent = AgenticServices
.sequenceBuilder(ExpertRouterAgent.class)
.subAgents(routerAgent, expertsAgent)
.outputKey("response")
.build();

String response = expertRouterAgent.ask("I broke my leg what should I do");

可选 Agent

在某些情况下,如果 AgenticScope 中没有工作流子 Agent 的输入参数,它可能无需执行。默认情况下,Agent 找不到其必需参数之一时,整个 Agentic 系统会因 MissingArgumentException 失败。不过可以将 Agent 标记为可选;当其任一参数缺失时会静默跳过执行,而不会导致整个工作流失败。

可通过 Agent builder 的 optional 方法实现。例如,对于上述顺序工作流,可以将 AudienceEditor Agent 设为可选,使输入中未提供受众时,仍可生成并进行风格编辑。

CreativeWriter creativeWriter = AgenticServices
.agentBuilder(CreativeWriter.class)
.chatModel(BASE_MODEL)
.outputKey("story")
.build();

AudienceEditor audienceEditor = AgenticServices
.agentBuilder(AudienceEditor.class)
.chatModel(BASE_MODEL)
.optional(true)
.outputKey("story")
.build();

StyleEditor styleEditor = AgenticServices
.agentBuilder(StyleEditor.class)
.chatModel(BASE_MODEL)
.outputKey("story")
.build();

UntypedAgent novelCreator = AgenticServices
.sequenceBuilder()
.subAgents(creativeWriter, audienceEditor, styleEditor)
.outputKey("story")
.build();

// No "audience" key is provided, so the audienceEditor will be skipped
Map<String, Object> input = Map.of(
"topic", "dragons and wizards",
"style", "fantasy"
);

String story = (String) novelCreator.invoke(input);

此处 audienceEditor Agent 被配置为可选。由于输入 map 未包含 AudienceEditor 所需的“audience”键,其调用会被跳过,工作流直接继续执行 StyleEditor。也可使用 @Agent 注解属性 @Agent(optional = true) 以声明式方式将同一 Agent 标记为可选。

异步 Agent

默认情况下,所有 Agent 调用均在调用 Agentic 系统根 Agent 的同一线程中执行,因此是同步的:系统会等待每个 Agent 完成后再继续下一个。但很多情况下并无必要,可以异步调用 Agent,让系统执行无需等待该 Agent 完成即可继续。

因此,可以通过 Agent builder 的 async 方法将 Agent 标记为异步。这样,该 Agent 的调用会在单独的线程中执行,Agentic 系统无需等待其完成即可继续。异步 Agent 完成后,其结果会立即在 AgenticScope 中可用;只有后续调用其他 Agent 需要该结果作为输入时,AgenticScope 才会阻塞等待。

例如,由于彼此独立,在每个子 Agent 上使用 .async(true) 将并行工作流章节讨论的 FoodExpertMovieExpert Agent 标记为异步,即使在顺序工作流中使用,它们也会同时执行。不同于 parallelBuilder()sequenceBuilder() 没有 executor() 方法;可选的 executor() 仅适用于并行工作流。

FoodExpert foodExpert = AgenticServices
.agentBuilder(FoodExpert.class)
.chatModel(BASE_MODEL)
.async(true)
.outputKey("meals")
.build();

MovieExpert movieExpert = AgenticServices
.agentBuilder(MovieExpert.class)
.chatModel(BASE_MODEL)
.async(true)
.outputKey("movies")
.build();

EveningPlannerAgent eveningPlannerAgent = AgenticServices
.sequenceBuilder(EveningPlannerAgent.class)
.subAgents(foodExpert, movieExpert)
.outputKey("plans")
.output(agenticScope -> {
List<String> movies = agenticScope.readState("movies", List.of());
List<String> meals = agenticScope.readState("meals", List.of());

List<EveningPlan> moviesAndMeals = new ArrayList<>();
for (int i = 0; i < movies.size(); i++) {
if (i >= meals.size()) {
break;
}
moviesAndMeals.add(new EveningPlan(movies.get(i), meals.get(i)));
}
return moviesAndMeals;
})
.build();

List<EveningPlan> plans = eveningPlannerAgent.plan("romantic");

流式 Agent

为支持流式输出,也可以创建返回 TokenStream 的 Agent。

public interface StreamingCreativeWriter {

@UserMessage("""
You are a creative writer.
Generate a draft of a story no more than
3 sentences long around the given topic.
Return only the story and nothing else.
The topic is {{topic}}.
""")
@Agent("Generates a story based on the given topic")
TokenStream generateStory(@V("topic") String topic);
}

然后将其配置为使用 StreamingChatModel,使结果能够在生成时被消费,而非等待 Agent 调用完成。

StreamingCreativeWriter creativeWriter = AgenticServices.agentBuilder(StreamingCreativeWriter.class)
.streamingChatModel(streamingBaseModel())
.outputKey("story")
.build();

TokenStream tokenStream = creativeWriter.generateStory("dragons and wizards");

在 Agentic 系统中使用时,流式 Agent 只有在作为最后一个被调用的 Agent 时,才能将流式响应传播给整个系统。其他情况下它的行为类似异步 Agent,因此后续 Agent 必须等待其流式响应完成,才能获取并使用结果。

例如,以下 StreamingReviewedWriter Agent:

public interface StreamingReviewedWriter {
@Agent
TokenStream writeStory(@V("topic") String topic, @V("audience") String audience, @V("style") String style);
}

由 3 个流式 Agent 的顺序组合实现:

StreamingCreativeWriter creativeWriter = AgenticServices.agentBuilder(StreamingCreativeWriter.class)
.streamingChatModel(streamingBaseModel())
.outputKey("story")
.build();

StreamingAudienceEditor audienceEditor = AgenticServices.agentBuilder(StreamingAudienceEditor.class)
.streamingChatModel(streamingBaseModel())
.outputKey("story")
.build();

StreamingStyleEditor styleEditor = AgenticServices.agentBuilder(StreamingStyleEditor.class)
.streamingChatModel(streamingBaseModel())
.outputKey("story")
.build();

StreamingReviewedWriter novelCreator = AgenticServices.sequenceBuilder(StreamingReviewedWriter.class)
.subAgents(creativeWriter, audienceEditor, styleEditor)
.outputKey("story")
.build();

调用此 novelCreator Agent 时:

TokenStream tokenStream = novelCreator.writeStory("dragons and wizards", "young adults", "fantasy");

在后续 Agent 调用开始前,前两个 Agent 的流式响应会在内部被完全消费;只有最后一个 StyleEditor Agent 的流式响应会作为整个 novelCreator Agent 的流式响应传播。

动态聊天模型选择

默认情况下,Agent 在构建时绑定单个 ChatModel。但有些场景下,你可能希望根据 Agentic 系统的当前状态,在每次调用时动态选择模型。例如,常规工作使用更便宜、更快的模型,在质量阈值要求时切换到能力更强的模型。

AgentBuilderchatModel 方法有一个接受 Function<AgenticScope, ChatModel> 的重载:

StoryEditor storyEditor = AgenticServices.agentBuilder(StoryEditor.class)
.chatModel(scope -> {
CritiqueResult critique = (CritiqueResult) scope.readState("critique");
return critique != null && critique.score() > 7.8 ? enhancedModel() : baseModel();
})
.outputKey("story")
.build();

该函数接收当前 AgenticScope,并返回当前调用要使用的 ChatModel。在本例中,待编辑故事的评论评分低于 7.8、仍有较多改进空间时,StoryEditor Agent 默认使用 baseModel();评分超过该阈值时,最终润色可能需要更好的模型,因此会切换到 enhancedModel()。该函数在每次调用前评估,因此同一 Agent 的不同调用可使用不同模型。

streamingChatModel 方法同样支持动态选择。

错误处理

在复杂的 Agentic 系统中,许多事情都可能出错,例如 Agent 未能生成结果、外部工具不可用,或 Agent 执行期间发生意外错误。

因此,errorHandler 方法允许为 Agentic 系统提供错误处理器;它是一个将如下定义的 ErrorContext 转换为结果的函数:

record ErrorContext(String agentName, AgenticScope agenticScope, AgentInvocationException exception) { }

转换为 ErrorRecoveryResult,其可以是以下 3 种情况之一:

  1. ErrorRecoveryResult.throwException():默认行为,仅将导致问题的 Exception 向上传播至根调用方
  2. ErrorRecoveryResult.retry():重试 Agent 调用,可能会先采取某些纠正措施
  3. ErrorRecoveryResult.result(Object result):忽略问题,并将提供的结果作为失败 Agent 的输出返回。

例如,如果在顺序工作流的第一个示例中省略了必需参数:

UntypedAgent novelCreator = AgenticServices
.sequenceBuilder()
.subAgents(creativeWriter, audienceEditor, styleEditor)
.outputKey("story")
.build();

Map<String, Object> input = Map.of(
// missing "topic" entry to trigger an error
// "topic", "dragons and wizards",
"style", "fantasy",
"audience", "young adults"
);

执行将因类似如下的异常而失败:

dev.langchain4j.agentic.agent.MissingArgumentException: Missing argument: topic

为解决此问题,可以为 Agent 配置适当的 errorHandler 来处理并恢复该错误,按如下方式向 agenticScope 提供缺失参数。

UntypedAgent novelCreator = AgenticServices.sequenceBuilder()
.subAgents(creativeWriter, audienceEditor, styleEditor)
.errorHandler(errorContext -> {
if (errorContext.agentName().equals("generateStory") &&
errorContext.exception() instanceof MissingArgumentException mEx && mEx.argumentName().equals("topic")) {
errorContext.agenticScope().writeState("topic", "dragons and wizards");
errorRecoveryCalled.set(true);
return ErrorRecoveryResult.retry();
}
return ErrorRecoveryResult.throwException();
})
.outputKey("story")
.build();

跨 Agent 补偿

当 Agentic 系统通过工具执行副作用(例如写入数据库、调用 API、金融交易)时,工作流执行到一半失败可能使系统处于不一致状态。跨 Agent 补偿旨在解决或至少缓解此问题:如果层级中的任一 Agent 失败,则所有此前成功且带有 @CompensateFor 操作的工具调用会按逆序补偿。

此功能构建于单 Agent 的 @CompensateFor 机制之上(参见工具)。单 Agent 补偿处理单个 Agent 内的工具错误,跨 Agent 补偿则处理整个层级中的 Agent 级失败。

要启用此功能,请在组合 Agent builder 上设置 compensateOnError(true)

UntypedAgent transferWorkflow = AgenticServices.sequenceBuilder()
.subAgents(creditAgent, debitAgent, notificationAgent)
.compensateOnError(true)
.outputKey("result")
.build();

如果 notificationAgent 抛出异常,creditAgentdebitAgent 调用且具有 @CompensateFor 方法的工具将按时间逆序(最后执行的优先)得到补偿。

没有 @CompensateFor 注解的工具会在补偿期间直接跳过。

补偿操作使用 @CompensateFor 定义在工具类上,使用的是与单 Agent 工具补偿相同的注解:

public class AccountService {

@Tool("Credits the given amount to the account")
String credit(@P(name = "amount") int amount) {
// perform the credit
return "credited " + amount;
}

@CompensateFor("credit")
void reverseCredit(int amount) {
// reverse the credit
}
}

请注意,补偿操作按尽力而为策略执行:若其中一项失败,则记录错误并继续执行剩余补偿。

可观测性

跟踪和记录 Agent 调用,对于调试及理解其所在整个 Agentic 系统的聚合行为至关重要。因此,langchain4j-agentic 模块允许通过 Agent builder 的 listener 方法注册 AgentListener;它会收到所有 Agent 调用及其结果的通知,定义如下:

public interface AgentListener {

default void beforeAgentInvocation(AgentRequest agentRequest) { }
default void afterAgentInvocation(AgentResponse agentResponse) { }
default void onAgentInvocationError(AgentInvocationError agentInvocationError) { }

default void afterAgenticScopeCreated(AgenticScope agenticScope) { }
default void beforeAgenticScopeDestroyed(AgenticScope agenticScope) { }

default void beforeToolExecution(BeforeToolExecution beforeToolExecution) { }
default void afterToolExecution(ToolExecution toolExecution) { }

default boolean inheritedBySubagents() {
return false;
}
}

请注意,此接口的所有方法都有默认空实现,因此只需实现感兴趣的方法。这也使未来版本可以新增方法而不破坏现有实现。

例如,以下 CreativeWriter Agent 配置会在调用时以及生成故事后向控制台记录日志。

CreativeWriter creativeWriter = AgenticServices.agentBuilder(CreativeWriter.class)
.chatModel(baseModel())
.outputKey("story")
.listener(new AgentListener() {
@Override
public void beforeAgentInvocation(AgentRequest request) {
System.out.println("Invoking CreativeWriter with topic: " + request.inputs().get("topic"));
}

@Override
public void afterAgentInvocation(AgentResponse response) {
System.out.println("CreativeWriter generated this story: " + response.output());
}
})
.build();

这些监听器方法分别接收 AgentRequestAgentResponse 参数,它们提供 Agent 调用的有用信息,例如名称、接收的输入、生成的输出,以及该调用使用的 AgenticScope 实例。请注意,这些方法与 Agent 调用在同一线程中执行,因此与其同步,不应执行长时间阻塞操作。

AgentListener 有两个重要特性:

  • 可组合:可多次调用 listener 方法,为同一 Agent 注册多个监听器;它们会按注册顺序收到通知;
  • 可选分层:默认仅对直接注册它们的 Agent 生效,但只需令其 inheritedBySubagents 方法返回 true,所有子 Agent 即可继承。在此情况下,注册在顶层 Agent 的监听器也会收到任意层级所有子 Agent 的调用通知,并与子 Agent 自身注册的全部监听器组合。

监控

利用 AgentListener 接口提供的可观测性功能,langchain4j-agentic 模块还提供了名为 AgentMonitor 的内置实现,其配置为可被所有子 Agent 继承。它旨在将所有 Agent 调用记录到内存树结构中,使你可以在 Agentic 系统执行期间或之后检查调用序列及其结果。可使用 Agent builder 的 listener 方法,将此监控器作为监听器注册到 Agentic 系统的根 Agent。

为提供更完整的示例,再次考虑用于生成并迭代润色故事直至满足所需风格质量的循环工作流,并为其注册若干监听器,其中包括 AgentMonitor

AgentMonitor monitor = new AgentMonitor();

CreativeWriter creativeWriter = AgenticServices.agentBuilder(CreativeWriter.class)
.listener(new AgentListener() {
@Override
public void beforeAgentInvocation(AgentRequest request) {
System.out.println("Invoking CreativeWriter with topic: " + request.inputs().get("topic"));
}
})
.chatModel(baseModel())
.outputKey("story")
.build();

StyleEditor styleEditor = AgenticServices.agentBuilder(StyleEditor.class)
.chatModel(baseModel())
.outputKey("story")
.build();

StyleScorer styleScorer = AgenticServices.agentBuilder(StyleScorer.class)
.name("styleScorer")
.chatModel(baseModel())
.outputKey("score")
.build();

UntypedAgent styleReviewLoop = AgenticServices.loopBuilder()
.subAgents(styleScorer, styleEditor)
.maxIterations(5)
.exitCondition(agenticScope -> agenticScope.readState("score", 0.0) >= 0.8)
.build();

UntypedAgent styledWriter = AgenticServices.sequenceBuilder()
.subAgents(creativeWriter, styleReviewLoop)
.listener(monitor)
.listener(new AgentListener() {
@Override
public void afterAgentInvocation(AgentResponse response) {
if (response.agentName().equals("styleScorer")) {
System.out.println("Current score: " + response.output());
}
}
})
.outputKey("story")
.build();

这里第一个监听器直接注册到 creativeWriter Agent,因此仅在调用该 Agent 时记录待生成故事的请求主题。第二个监听器注册到顶层 styledWriter Agent,因此该 Agent 层级任意层级的所有子 Agent 调用也会触发它。这就是该监听器的 afterAgentInvocation 方法检查被调用 Agent 是否为 styleScorer 的原因;只有在该情况下才记录生成故事风格当前获得的评分。

最后,AgentMonitor 实例也被注册为 styledWriter 顶层 Agent 的另一监听器,并自动与另外两个监听器组合,以便跟踪整个 Agentic 系统中所有 Agent 的调用。

如下调用 styledWriter Agent 时:

Map<String, Object> input = Map.of(
"topic", "dragons and wizards",
"style", "comedy");
String story = styledWriter.invoke(input);

AgentMonitor 会将所有 Agent 调用记录为树结构,其中还记录每次调用的开始时间、结束时间、持续时间、Token、输入和输出。此时可从监控器获取记录的执行信息,例如将其打印到控制台检查。

MonitoredExecution execution = monitor.successfulExecutions().get(0);
System.out.println(execution);

这样将展示生成和润色故事所需的嵌套 Agent 调用序列,如下所示:

AgentInvocation{agent=Sequential, startTime=2026-03-18T17:27:28.099439515, finishTime=2026-03-18T17:27:38.683498783, duration=10584 ms, tokens=0, inputs={topic=dragons and wiz..., style=comedy}, output=In a realm wher...}
|=> AgentInvocation{agent=generateStory, startTime=2026-03-18T17:27:28.1.18.1287, finishTime=2026-03-18T17:27:31.033561726, duration=2932 ms, tokens=127, inputs={topic=dragons and wiz...}, output=In a realm wher...}
|=> AgentInvocation{agent=reviewLoop, startTime=2026-03-18T17:27:31.035952285, finishTime=2026-03-18T17:27:38.683438433, duration=7647 ms, tokens=0, inputs={score=0.8, topic=dragons and wiz..., style=comedy, story=In a realm wher...}, output=null}
|=> AgentInvocation{agent=scoreStyle, iteration=0, startTime=2026-03-18T17:27:31.036155107, finishTime=2026-03-18T17:27:31.671478699, duration=635 ms, tokens=152, inputs={style=comedy, story=In a realm wher...}, output=0.2}
|=> AgentInvocation{agent=editStory, iteration=0, startTime=2026-03-18T17:27:31.671711250, finishTime=2026-03-18T17:27:38.182881941, duration=6511 ms, tokens=491, inputs={style=comedy, story=In a realm wher...}, output=In a realm wher...}
|=> AgentInvocation{agent=scoreStyle, iteration=1, startTime=2026-03-18T17:27:38.183021641, finishTime=2026-03-18T17:27:38.683085876, duration=500 ms, tokens=439, inputs={style=comedy, story=In a realm wher...}, output=0.8}

最后,也可使用 HtmlReportGenerator 类公开的静态 generateReport 方法,针对 Agentic 系统拓扑和记录的执行情况,生成 AgentMonitor 收集数据的可视化 HTML 报告。例如,为前述执行生成此报告:

HtmlReportGenerator.generateReport(monitor, Path.of("review-loop.html"));

会在当前工作目录生成类似如下的报告文件 review-loop.html

也可以独立生成拓扑和执行部分。要仅生成 Agentic 系统拓扑且不包含任何执行数据:

HtmlReportGenerator.generateTopology(styledWriter, Path.of("topology.html"));

反之,要仅生成监控器记录的执行历史而不包含拓扑部分:

HtmlReportGenerator.generateExecution(monitor, Path.of("execution.html"));

最后一个方法还支持按 memory id 过滤,例如 HtmlReportGenerator.generateExecution(monitor, memoryId, path);所有方法都有返回 HTML String 而非写入文件的重载。

默认情况下,AgentMonitor 针对每种结果(成功和失败,彼此独立)最多保留 100 个会话(不同 memory ID)。超过限制时会自动逐出最旧会话。这使得可以安全地将监控器附加到长期存在的单例 Agent,而无需担心内存无限增长。

可随时通过 setMaxRetainedSessions 修改保留上限。新上限低于当前保留会话数时,超出的条目会立即被逐出:

monitor.setMaxRetainedSessions(20);

设置为 0 会完全禁用保留——监听器回调仍会触发,但内存中不保留任何内容。要显式移除所有保留的会话,请使用 clear() 方法:

monitor.clear();

两项操作均不会影响正在进行的(in-flight)执行。

除了手动创建 AgentMonitor 并将其注册为监听器外,还可以让 Agent 服务接口扩展 MonitoredAgent。这样 builder 会自动创建并注册 AgentMonitor 作为监听器,且可通过 agentMonitor() 方法直接从 Agent 实例访问该监控器。

例如,通过定义同时扩展 MonitoredAgent 接口的 StyledWriter 接口,可以将前述示例中的顺序 Agent 转换为类型化且受监控的 Agent:

public interface StyledWriter extends MonitoredAgent {
@Agent("Write a creative story about the given topic")
String generateStoryWithStyle(@V("topic") String topic, @V("style") String style);
}

构建该 Agent 时,无需显式创建或注册 AgentMonitor

StyledWriter styledWriter = AgenticServices.sequenceBuilder(StyledWriter.class)
.subAgents(creativeWriter, styleReviewLoop)
.outputKey("story")
.build();

监控器会自动注册,并可随时从 Agent 自身获取:

AgentMonitor monitor = styledWriter.agentMonitor();

声明式 API

至此讨论的全部工作流模式都可以使用声明式 API 定义,它能以更简洁、易读的方式定义工作流。langchain4j-agentic 模块提供一组注解,可用于以更声明式的风格定义 Agent 及其工作流。

例如,可以使用声明式 API 将前一节以编程方式定义的、实现并行工作流的 EveningPlannerAgent 重写如下:

public interface EveningPlannerAgent {

@ParallelAgent( outputKey = "plans",
subAgents = { FoodExpert.class, MovieExpert.class })
List<EveningPlan> plan(@V("mood") String mood);

@ParallelExecutor
static Executor executor() {
return Executors.newFixedThreadPool(2);
}

@Output
static List<EveningPlan> createPlans(@V("movies") List<String> movies, @V("meals") List<String> meals) {
List<EveningPlan> moviesAndMeals = new ArrayList<>();
for (int i = 0; i < movies.size(); i++) {
if (i >= meals.size()) {
break;
}
moviesAndMeals.add(new EveningPlan(movies.get(i), meals.get(i)));
}
return moviesAndMeals;
}
}

在此情况下,标注 @Output 的静态方法用于定义如何将子 Agent 的输出组合为单个结果,其方式与向 output 方法传递 AgenticScope 函数完全相同。

定义此接口后,可使用 AgenticServices.createAgenticSystem() 方法创建 EveningPlannerAgent 实例,然后与之前完全一样地使用它。

EveningPlannerAgent eveningPlannerAgent = AgenticServices
.createAgenticSystem(EveningPlannerAgent.class, BASE_MODEL);
List<EveningPlan> plans = eveningPlannerAgent.plan("romantic");

@Output 注解所演示的方式类似,在定义 Agentic 模式的接口中,将其他 static 方法标注为以下任一注解,即可声明式地配置 Agentic 系统,例如并行 Agent 使用的 executor、循环 Agent 的退出条件等。可用于此目的的注解列表如下:

注解名称说明
@Output组合该 Agentic 模式要返回的输出,汇集 AgenticScope 的不同状态。
@ActivationCondition仅适用于 ConditionalAgent,用于为一个或多个子 Agent 定义激活谓词,必须返回 boolean
@BeforeCall调用此 Agentic 模式前执行的操作,可用于初始化 AgenticScope 状态。
@ErrorHandlerAgent 操作期间发生错误时调用的操作,支持自定义错误处理逻辑。
@ExitCondition仅适用于 LoopAgent,用于定义循环退出谓词,必须返回 boolean
@ParallelExecutor仅适用于 ParallelAgentParallelMapperAgent,指定并行运行子 Agent 的 executor。
@AgentListenerSupplier返回注册到此 Agentic 模式的 AgentListener
@PlannerSupplier返回此 Agentic 模式使用的 Planner 实现。
@SupervisorRequest仅适用于 SupervisorAgent,定义将发送给 supervisor 的请求。

在前述示例中,还向 AgenticServices.createAgenticSystem() 方法提供了 ChatModel,默认用于创建此 Agentic 系统中的所有子 Agent。不过,也可以在指定子 Agent 的定义中添加一个标注 @ChatModelSupplier 的静态方法,返回该 Agent 要使用的 ChatModel,从而为其选择不同模型。例如,FoodExpert Agent 可以如下定义自己的 ChatModel

public interface FoodExpert {

@UserMessage("""
You are a great evening planner.
Propose a list of 3 meals matching the given mood.
The mood is {{mood}}.
For each meal, just give the name of the meal.
Provide a list with the 3 items and nothing else.
""")
@Agent(outputKey = "meals")
List<String> findMeal(@V("mood") String mood);

@ChatModelSupplier
static ChatModel chatModel() {
return FOOD_MODEL;
}
}

以非常相似的方式,标注 Agent 接口中的其他 static 方法,可以声明式地配置 Agent 的其他方面,例如聊天记忆、可用工具等。请注意,前一组注解仅适用于 Agentic 模式;下列注解仅适用于基于 LLM 的最终 Agent 更合理,例外是 @AgentListenerSupplier,它可在 Agentic 模式和最终 Agent 上注册监听器。此外,由于 supervisor 模式是唯一内部使用 LLM 的模式,也可在其上使用配置 supervisor 自身 ChatModel 的注解,如 @ChatModelSupplier@ChatMemoryProviderSupplier,。除下表另有说明外,这些方法不得有参数。可用于此目的的注解列表如下:

注解名称说明
@ChatModelSupplier返回此 Agent 要使用的 ChatModel。若方法无参数,可以是固定模型;也可以是 AgenticScope 的函数。
@StreamingChatModelSupplier返回此 Agent 要使用的 StreamingChatModel。若方法无参数,可以是固定模型;也可以是 AgenticScope 的函数。
@ChatMemorySupplier返回此 Agent 要使用的 ChatMemory
@ChatMemoryProviderSupplier返回此 Agent 要使用的 ChatMemoryProvider
此方法需要一个 Object 参数,作为所创建记忆的 memoryId。
@ContentRetrieverSupplier返回此 Agent 要使用的 ContentRetriever
@AgentListenerSupplier返回此 Agent 要使用的 AgentListener
@RetrievalAugmentorSupplier返回此 Agent 要使用的 RetrievalAugmentor
@ToolsSupplier返回此 Agent 要使用的一个工具或一组工具。
可返回单个 ObjectObject[]
@ToolProviderSupplier返回此 Agent 要使用的 ToolProvider
@SystemMessageProviderSupplier提供动态系统消息。该方法接收 Object 类型的 memoryId,并返回 String
@UserMessageProviderSupplier提供动态用户消息。该方法接收 Object 类型的 memoryId,并返回 String

为再举一个声明式 API 示例,下面通过它重新定义条件工作流章节展示的 ExpertsAgent

public interface ExpertsAgent {

@ConditionalAgent(outputKey = "response",
subAgents = { MedicalExpert.class, TechnicalExpert.class, LegalExpert.class })
String askExpert(@V("request") String request);

@ActivationCondition(MedicalExpert.class)
static boolean activateMedical(@V("category") RequestCategory category) {
return category == RequestCategory.MEDICAL;
}

@ActivationCondition(TechnicalExpert.class)
static boolean activateTechnical(@V("category") RequestCategory category) {
return category == RequestCategory.TECHNICAL;
}

@ActivationCondition(LegalExpert.class)
static boolean activateLegal(@V("category") RequestCategory category) {
return category == RequestCategory.LEGAL;
}
}

在此情况下,@ActivationCondition 注解的值指向一组 agent 类:当被该注解标注的方法返回 true 时,这些 agent 类将被激活。

请注意,也可以混合使用编程式和声明式风格来定义 agent 与 agent 系统,因此一个 agent 可以部分通过注解、部分通过 agent builder 配置。还可以完全以声明方式定义一个 agent,然后以该 agent 的类作为子 agent,通过编程方式实现一个 agent 系统。例如,可以按如下方式声明定义 CreativeWriterAudienceEditor agent:

public interface CreativeWriter {

@UserMessage("""
You are a creative writer.
Generate a draft of a story long no more than 3 sentence around the given topic.
Return only the story and nothing else.
The topic is {{topic}}.
""")
@Agent(description = "Generate a story based on the given topic", outputKey = "story")
String generateStory(@V("topic") String topic);

@ChatModelSupplier
static ChatModel chatModel() {
return baseModel();
}
}

public interface AudienceEditor {

@UserMessage("""
You are a professional editor.
Analyze and rewrite the following story to better align with the target audience of {{audience}}.
Return only the story and nothing else.
The story is "{{story}}".
""")
@Agent(description = "Edit a story to better fit a given audience", outputKey = "story")
String editStory(@V("story") String story, @V("audience") String audience);

@ChatModelSupplier
static ChatModel chatModel() {
return baseModel();
}
}

随后,只需将它们的类作为子 agent,就可以通过编程方式在一个序列中串联它们。

UntypedAgent novelCreator = AgenticServices.sequenceBuilder()
.subAgents(CreativeWriter.class, AudienceEditor.class)
.outputKey("story")
.build();

Map<String, Object> input = Map.of(
"topic", "dragons and wizards",
"audience", "young adults"
);

String story = (String) novelCreator.invoke(input);

强类型输入和输出

到目前为止,所有用于向 agent 传递数据及从 agent 获取数据的输入和输出键,均由简单的 String 标识。然而,这种方式依赖于键名的正确拼写,因此容易出错。它也无法将这些变量强绑定到特定类型,因而在从 AgenticScope 读取其值时,必须进行类型检查和转换。为避免这些问题,可以选择通过 TypedKey 接口定义强类型的输入和输出键。

例如,采用这种方式,可以如下定义在介绍条件工作流时讨论的专家路由示例中使用的输入和输出键:

public static class UserRequest implements TypedKey<String> { }

public static class ExpertResponse implements TypedKey<String> { }

public static class Category implements TypedKey<RequestCategory> {
@Override
public Category defaultValue() {
return Category.UNKNOWN;
}
}

这里,UserRequestExpertResponse 两个键都被强类型化为 String,而 Category 键则被类型化为 RequestCategory 枚举;当该键不存在于 AgenticScope 时,它还会提供一个默认值。使用这些类型化键后,用于对用户请求分类的 CategoryRouter agent 可以重新定义如下:

public interface CategoryRouter {

@UserMessage("""
Analyze the following user request and categorize it as 'legal', 'medical' or 'technical'.
In case the request doesn't belong to any of those categories categorize it as 'unknown'.
Reply with only one of those words and nothing else.
The user request is: '{{UserRequest}}'.
""")
@Agent(description = "Categorizes a user request", typedOutputKey = Category.class)
RequestCategory classify(@K(UserRequest.class) String request);
}

classify 方法的参数现在使用 @K 注解,表明其值必须从由 UserRequest 类型化键标识的 AgenticScope 变量中取得。同样,此 agent 的输出会写入由 Category 类型化键标识的 AgenticScope 变量。请注意,提示模板也更新为使用类型化键的名称;默认情况下,该名称对应于实现 TypedKey 接口的类的简单名称,本例中为 {{UserRequest}},但也可以通过实现 TypedKey 接口的 name() 方法覆盖这一约定。以类似方式,三个专家 agent 之一的 MedicalExpert 可以重新定义如下:

public interface MedicalExpert {

@UserMessage("""
You are a medical expert.
Analyze the following user request under a medical point of view and provide the best possible answer.
The user request is {{UserRequest}}.
""")
@Agent("A medical expert")
String medical(@K(UserRequest.class) String request);
}

现在,可以使用这些类型化键来标识 AgenticScope 中的输入和输出变量,从而创建整个 agent 系统。

CategoryRouter routerAgent = AgenticServices.agentBuilder(CategoryRouter.class)
.chatModel(baseModel())
.build();

MedicalExpert medicalExpert = AgenticServices.agentBuilder(MedicalExpert.class)
.chatModel(baseModel())
.outputKey(ExpertResponse.class)
.build();
LegalExpert legalExpert = AgenticServices.agentBuilder(LegalExpert.class)
.chatModel(baseModel())
.outputKey(ExpertResponse.class)
.build();
TechnicalExpert technicalExpert = AgenticServices.agentBuilder(TechnicalExpert.class)
.chatModel(baseModel())
.outputKey(ExpertResponse.class)
.build();

UntypedAgent expertsAgent = AgenticServices.conditionalBuilder()
.subAgents(scope -> scope.readState(Category.class) == Category.MEDICAL, medicalExpert)
.subAgents(scope -> scope.readState(Category.class) == Category.LEGAL, legalExpert)
.subAgents(scope -> scope.readState(Category.class) == Category.TECHNICAL, technicalExpert)
.build();

ExpertChatbot expertChatbot = AgenticServices.sequenceBuilder(ExpertChatbot.class)
.subAgents(routerAgent, expertsAgent)
.outputKey(ExpertResponse.class)
.build();

String response = expertChatbot.ask("I broke my leg what should I do");

routerAgent 无需以编程方式指定输出键,因为它已通过接口中 @Agent 注解的 typedOutputKey 属性定义;而三个专家 agent 的接口尚未定义输出键,因此仍需通过编程方式指定。与往常一样,两种方式均可使用。还值得注意的是,从 AgenticScope 中读取值时(如条件工作流定义中所示),无需执行任何类型检查或转换,因为类型化键已提供必要的类型信息。

纯 agentic AI

到目前为止,所有 agent 都通过确定性工作流进行连接和组合,以创建 agent 系统。然而,在某些场景中,agent 系统需要更灵活、更具适应性,使 agent 能够根据上下文以及先前交互的结果决定如何继续。这通常被称为“纯 agentic AI”。

为此,langchain4j-agentic 模块开箱即用地提供了一个 supervisor agent。它可以接收一组子 agent,并自主生成计划,决定接下来调用哪个 agent,或判断分配的任务是否已经完成。

为说明其工作方式,我们定义几个 agent,它们可以向银行账户存入或取出资金,或者将指定金额从一种货币兑换为另一种货币。

public interface WithdrawAgent {

@SystemMessage("""
You are a banker that can only withdraw US dollars (USD) from a user account,
""")
@UserMessage("""
Withdraw {{amount}} USD from {{user}}'s account and return the new balance.
""")
@Agent("A banker that withdraw USD from an account")
String withdraw(@V("user") String user, @V("amount") Double amount);
}

public interface CreditAgent {
@SystemMessage("""
You are a banker that can only credit US dollars (USD) to a user account,
""")
@UserMessage("""
Credit {{amount}} USD to {{user}}'s account and return the new balance.
""")
@Agent("A banker that credit USD to an account")
String credit(@V("user") String user, @V("amount") Double amount);
}

public interface ExchangeAgent {
@UserMessage("""
You are an operator exchanging money in different currencies.
Use the tool to exchange {{amount}} {{originalCurrency}} into {{targetCurrency}}
returning only the final amount provided by the tool as it is and nothing else.
""")
@Agent("A money exchanger that converts a given amount of money from the original to the target currency")
Double exchange(@V("originalCurrency") String originalCurrency, @V("amount") Double amount, @V("targetCurrency") String targetCurrency);
}

这些 agent 都使用外部工具来完成任务,具体而言是可用于从用户账户中取款或存款的 BankTool

public class BankTool {

private final Map<String, Double> accounts = new HashMap<>();

void createAccount(String user, Double initialBalance) {
if (accounts.containsKey(user)) {
throw new RuntimeException("Account for user " + user + " already exists");
}
accounts.put(user, initialBalance);
}

double getBalance(String user) {
Double balance = accounts.get(user);
if (balance == null) {
throw new RuntimeException("No balance found for user " + user);
}
return balance;
}

@Tool("Credit the given user with the given amount and return the new balance")
Double credit(@P("user name") String user, @P("amount") Double amount) {
Double balance = accounts.get(user);
if (balance == null) {
throw new RuntimeException("No balance found for user " + user);
}
Double newBalance = balance + amount;
accounts.put(user, newBalance);
return newBalance;
}

@Tool("Withdraw the given amount with the given user and return the new balance")
Double withdraw(@P("user name") String user, @P("amount") Double amount) {
Double balance = accounts.get(user);
if (balance == null) {
throw new RuntimeException("No balance found for user " + user);
}
Double newBalance = balance - amount;
accounts.put(user, newBalance);
return newBalance;
}
}

以及可用于将资金从一种货币兑换为另一种货币的 ExchangeTool,它或许会使用提供最新汇率的 REST 服务。

public class ExchangeTool {

@Tool("Exchange the given amount of money from the original to the target currency")
Double exchange(@P("originalCurrency") String originalCurrency, @P("amount") Double amount, @P("targetCurrency") String targetCurrency) {
// Invoke a REST service to get the exchange rate
}
}

现在可以像往常一样使用 AgenticServices.agentBuilder() 方法创建这些 agent 的实例,将其配置为使用这些工具,然后将它们用作 supervisor agent 的子 agent。

BankTool bankTool = new BankTool();
bankTool.createAccount("Mario", 1000.0);
bankTool.createAccount("Georgios", 1000.0);

WithdrawAgent withdrawAgent = AgenticServices
.agentBuilder(WithdrawAgent.class)
.chatModel(BASE_MODEL)
.tools(bankTool)
.build();
CreditAgent creditAgent = AgenticServices
.agentBuilder(CreditAgent.class)
.chatModel(BASE_MODEL)
.tools(bankTool)
.build();

ExchangeAgent exchangeAgent = AgenticServices
.agentBuilder(ExchangeAgent.class)
.chatModel(BASE_MODEL)
.tools(new ExchangeTool())
.build();

SupervisorAgent bankSupervisor = AgenticServices
.supervisorBuilder()
.chatModel(PLANNER_MODEL)
.subAgents(withdrawAgent, creditAgent, exchangeAgent)
.responseStrategy(SupervisorResponseStrategy.SUMMARY)
.build();

请注意,子 agent 也可以是实现某个工作流的复杂 agent;supervisor 会将其视为单个 agent。

生成的 SupervisorAgent 通常以用户请求作为输入并产生响应,因此其签名很简单,如下所示:

public interface SupervisorAgent {
@Agent
String invoke(@V("request") String request);
}

现在,假设使用以下请求调用该 agent:

bankSupervisor.invoke("Transfer 100 EUR from Mario's account to Georgios' one")

内部发生的情况是,supervisor agent 会分析请求,并生成一个由一系列 AgentInvocation 组成的计划来完成任务。

public record AgentInvocation(String agentName, Map<String, String> arguments) {}

例如,对于前述请求,supervisor 可以生成如下调用序列:

AgentInvocation{agentName='exchange', arguments={originalCurrency=EUR, amount=100, targetCurrency=USD}}

AgentInvocation{agentName='withdraw', arguments={user=Mario, amount=115.0}}

AgentInvocation{agentName='credit', arguments={user=Georgios, amount=115.0}}

AgentInvocation{agentName='done', arguments={response=The transfer of 100 EUR from Mario's account to Georgios' account has been completed. Mario's balance is 885.0 USD, and Georgios' balance is 1.18.1 USD. The conversion rate was 1.15 EUR to USD.}}

最后一次调用是特殊调用,表示 supervisor 认为任务已完成,并返回包含所有已执行操作摘要的响应。

在许多情况下(如本例),该摘要就是应返回给用户的最终响应,但并非总是如此。假设你不使用普通的序列工作流,而是使用 SupervisorAgent 按第一个示例所示创建故事,并根据给定的风格和受众进行编辑。在这种情况下,用户只关心最终故事,而不关心创建该故事所采取中间步骤的摘要。

返回最后调用的 agent 所生成的响应而不是摘要,实际上是最常见的场景,因此也是 supervisor agent 的默认行为。不过,对于此场景,返回所有已执行交易的摘要更合适,因此已通过 responseStrategy 方法相应配置 SupervisorAgent

下一节将讨论这一点以及 supervisor agent 的其他可选自定义方式。

Supervisor 设计与自定义

更一般地说,在某些情况下,无法预先得知 supervisor 生成的摘要和最后调用的 agent 的最后响应中,哪一个更适合返回。为此,系统提供了第二个 agent:它会接收这两个候选响应以及原始用户请求,并为它们打分,决定哪一个更符合请求,从而确定应返回哪一个。

SupervisorResponseStrategy 枚举可以启用这个评分 agent,或者始终返回两个响应之一并跳过评分过程。

public enum SupervisorResponseStrategy {
SCORED, SUMMARY, LAST
}

如前所述,默认行为为 LAST,其他策略实现可通过 responseStrategy 方法在 supervisor agent 上配置。

AgenticServices.supervisorBuilder()
.responseStrategy(SupervisorResponseStrategy.SCORED)
.build();

例如,在银行示例中使用 SCORED 策略时,可能产生如下响应评分:

ResponseScore{finalResponse=0.3, summary=1.0}

从而使 supervisor agent 将摘要作为用户请求的最终响应返回。

截至目前所述的 supervisor agent 架构如下图所示:

supervisor 用于决定下一步动作的信息是其另一个关键方面。默认情况下,supervisor 仅使用本地聊天记忆;但在某些情况下,为它提供更全面的上下文会很有用。该上下文可通过汇总其子 agent 的对话生成,与上下文工程章节所讨论的方式非常相似;也可以同时结合两种方法。以下枚举表示三种可能性:

public enum SupervisorContextStrategy {
CHAT_MEMORY, SUMMARIZATION, CHAT_MEMORY_AND_SUMMARIZATION
}

可在构建 supervisor agent 时通过 contextGenerationStrategy 方法设置:

AgenticServices.supervisorBuilder()
.contextGenerationStrategy(SupervisorContextStrategy.SUMMARIZATION)
.build();

未来可能会为 supervisor agent 实现并提供其他自定义扩展点。

向 supervisor 提供上下文

在许多实际场景中,supervisor 可受益于可选上下文:应指导规划过程的约束、策略或偏好(例如“优先使用内部工具”“不得调用外部服务”“货币必须为 USD”等)。

该上下文存储在 AgenticScope 中名为 supervisorContext 的变量中。可通过两种方式提供:

  • 构建时配置:
SupervisorAgent bankSupervisor = AgenticServices
.supervisorBuilder()
.chatModel(PLANNER_MODEL)
.supervisorContext("Policies: prefer internal tools; currency USD; no external APIs")
.subAgents(withdrawAgent, creditAgent, exchangeAgent)
.responseStrategy(SupervisorResponseStrategy.SUMMARY)
.build();
  • 调用时(类型化 supervisor):添加一个带有 @V("supervisorContext") 注解的参数:
public interface SupervisorAgent {
@Agent
String invoke(@V("request") String request, @V("supervisorContext") String supervisorContext);
}

// Example call (overrides the build-time value for this invocation)
bankSupervisor.invoke(
"Transfer 100 EUR from Mario's account to Georgios' one",
"Policies: convert to USD first; use bank tools only; no external APIs"
);
  • 调用时(非类型化 supervisor):在输入 map 中设置 supervisorContext
Map<String, Object> input = Map.of(
"request", "Transfer 100 EUR from Mario's account to Georgios' one",
"supervisorContext", "Policies: convert to USD first; use bank tools only; no external APIs"
);

String result = (String) bankSupervisor.invoke(input);

如果两者都提供,调用时的值会覆盖构建时的 supervisorContext

自定义 agentic 模式

到目前为止讨论的 agentic 模式由 langchain4j-agentic 模块开箱即用地提供。但如果它们都不符合应用程序的特定需求,该怎么办?在这种情况下,可以创建自己的自定义模式,以适合需求的方式编排一组子 agent 之间的交互。

更具体地说,agentic 模式只是其所协调的子 agent 的执行计划规范。可通过实现以下 Planner 接口来定义该计划:

public interface Planner {

default void init(InitPlanningContext initPlanningContext) { }

default Action firstAction(PlanningContext planningContext) {
return nextAction(planningContext);
}

Action nextAction(PlanningContext planningContext);
}

该接口有三个方法:initfirstActionnextActioninit 方法在执行开始时调用一次,可用于初始化 planner 所需的任何状态或数据结构。firstAction 方法用于确定 agentic 模式应执行的第一个动作;nextAction 方法则在每次 agent 执行后调用,根据 AgenticScope 的当前状态和前一个 agent 的执行结果确定下一个动作。

请注意,引入 firstAction 方法只是因为在许多情况下,为 Planner 定义第一个要调用的 agent 提供一个独立回调更方便。但是,如果不需要这种区分,它提供了一个默认实现,仅将调用转发给 nextAction 方法,因此并非必须重写它。

firstActionnextAction 方法返回的 Action 类表示 agentic 模式应采取的下一步,它可以是接下来要调用的一个或多个子 agent 的列表,也可以是表示执行已完成的信号。如果该动作仅指定一次子 agent 调用,则它会以顺序方式在执行 planner 本身的同一线程中执行;如果指定多个子 agent,则会使用提供的 Executor 或 LangChain4j 的默认 executor 并行执行。

所有内置 agentic 模式也都基于此 Planner 抽象编写。查看其实现有助于理解工作原理,也可以作为创建自定义模式的良好起点。例如,并行工作流可能是这些实现中最简单的一个,定义如下:

public class ParallelPlanner implements Planner {

private List<AgentInstance> agents;

@Override
public void init(InitPlanningContext initPlanningContext) {
this.agents = initPlanningContext.subagents();
}

@Override
public Action firstAction(PlanningContext planningContext) {
return call(agents);
}

@Override
public Action nextAction(PlanningContext planningContext) {
return done();
}
}

这里,init 方法仅存储配置给并行工作流的子 agent 列表,而 firstAction 方法返回一个并行调用所有这些 agent 的动作。该并行执行完成后,不再有其他动作需要执行,因此 nextAction 方法只需返回用于表示执行终止的 done()

实现顺序工作流的 Planner 只稍微复杂一些,因为它需要使用内部游标跟踪下一个要调用的子 agent,然后在 nextAction 方法中返回适当的动作;或者在所有子 agent 都已调用后表示执行终止。

public class SequentialPlanner implements Planner {

private List<AgentInstance> agents;
private int agentCursor = 0;

@Override
public void init(InitPlanningContext initPlanningContext) {
this.agents = initPlanningContext.subagents();
}

@Override
public Action nextAction(PlanningContext planningContext) {
return agentCursor >= agents.size() ? done() : call(agents.get(agentCursor++));
}
}

要理解如何根据 planner 实现定义 agentic 系统,例如可以创建一个前文讨论过的顺序工作流实例:它围绕某个主题生成小说,然后针对特定风格和受众进行编辑,如下所示:

UntypedAgent novelCreator = AgenticServices.plannerBuilder()
.subAgents(creativeWriter, audienceEditor, styleEditor)
.outputKey("story")
.planner(SequentialPlanner::new)
.build();

这与使用顺序工作流的专用 API 完全等价:

UntypedAgent novelCreator = AgenticServices.sequenceBuilder()
.subAgents(creativeWriter, audienceEditor, styleEditor)
.outputKey("story")
.build();

plannerBuilder() 方法与所有其他 agent builder 类似,唯一差别是它要求提供一个 Supplier<Planner>,该 supplier 返回此 agentic 系统所使用的特定 planner 的新实例。当然,实现自定义 planner 的 agentic 系统可以与 langchain4j-agentic 模块开箱即用提供的任何其他 agentic 模式无缝组合。

明确了该 Planner 抽象的工作方式后,现在可以通过实现它来创建自己的自定义 agentic 模式。以下章节讨论了 langchain4j-agentic-patterns 模块中提供的两个自定义模式示例,它们可用于不同场景。其他自定义模式也可以使用相同方法创建,并可能贡献回 LangChain4j 项目。

目标导向 agentic 模式

工作流模式和 supervisor agent 代表了可能的 agentic 系统谱系的两个极端:前者完全确定且僵化,迫使你预先决定要调用的 agent 序列;后者则完全灵活且具备适应性,却将待调用 agent 序列的决策交给非确定性的 LLM。然而,在某些情况下,这两个极端之间的中间方案可能更合适:它允许 agent 以相对灵活的方式朝着特定目标工作,同时仍通过算法确定应如何调用这些 agent。

为实践这种方法,不仅整个 agentic 系统需要定义目标,每个子 agent 也需要声明自己的前置和后置条件。这是计算最快达成目标的 agent 调用序列所必需的。不过,所有这些信息其实已经隐含在 agentic 系统中,因为这些前置和后置条件无非就是每个 agent 所需的输入和产生的输出,而最终目标只是整个 agentic 系统所期望的输出。

基于这一思路,可以计算出参与 agentic 系统的所有子 agent 的依赖图,然后实现一个 Planner:它能够分析 AgenticScope 的初始状态,将其与期望目标进行比较,并利用该图确定可以达成目标的 agent 调用序列。

public class GoalOrientedPlanner implements Planner {

private String goal;

private GoalOrientedSearchGraph graph;
private List<AgentInstance> path;

private int agentCursor = 0;

@Override
public void init(InitPlanningContext initPlanningContext) {
this.goal = initPlanningContext.plannerAgent().outputKey();
this.graph = new GoalOrientedSearchGraph(initPlanningContext.subagents());
}

@Override
public Action firstAction(PlanningContext planningContext) {
path = graph.search(planningContext.agenticScope().state().keySet(), goal);
if (path.isEmpty()) {
throw new IllegalStateException("No path found for goal: " + goal);
}
return call(path.get(agentCursor++));
}

@Override
public Action nextAction(PlanningContext planningContext) {
return agentCursor >= path.size() ? done() : call(path.get(agentCursor++));
}
}

如前所述,此处目标与基于 planner 的 agentic 模式自身的最终输出一致,而从初始状态到目标的路径通过 GoalOrientedSearchGraph 计算得出;该图通过分析所有子 agent 的输入和输出键构建。接着,将 agent 调用序列计算为该图上从当前状态到期望目标的最短路径。

为给出其工作方式的实际示例,让我们构建一个目标导向的 agentic 系统:它能够从提示中提取人员姓名和星座,为该星座生成星座运势,在互联网搜索相关文章,最后创建一篇结合所有这些信息的精彩文章。可使用以下五个 agent 完成这组任务:

public interface HoroscopeGenerator {
@SystemMessage("You are an astrologist that generates horoscopes based on the user's name and zodiac sign.")
@UserMessage("Generate the horoscope for {{person}} who is a {{sign}}.")
@Agent("An astrologist that generates horoscopes based on the user's name and zodiac sign.")
String horoscope(@V("person") Person person, @V("sign") Sign sign);
}

public interface PersonExtractor {

@UserMessage("Extract a person from the following prompt: {{prompt}}")
@Agent("Extract a person from user's prompt")
Person extractPerson(@V("prompt") String prompt);
}

public interface SignExtractor {

@UserMessage("Extract the zodiac sign of a person from the following prompt: {{prompt}}")
@Agent("Extract a person from user's prompt")
Sign extractSign(@V("prompt") String prompt);
}

public interface Writer {
@UserMessage("""
Create an amusing writeup for {{person}} based on the following:
- their horoscope: {{horoscope}}
- a current news story: {{story}}
""")
@Agent("Create an amusing writeup for the target person based on their horoscope and current news stories")
String write(@V("person") Person person, @V("horoscope") String horoscope, @V("story") String story);
}

public interface StoryFinder {

@SystemMessage("""
You're a story finder, use the provided web search tools, calling it once and only once,
to find a fictional and funny story on the internet about the user provided topic.
""")
@UserMessage("""
Find a story on the internet for {{person}} who has the following horoscope: {{horoscope}}.
""")
@Agent("Find a story on the internet for a given person with a given horoscope")
String findStory(@V("person") Person person, @V("horoscope") String horoscope);
}

利用前面开发的 GoalOrientedPlanner,可按如下方式将这些 agent 组合为目标导向的 agentic 系统:

HoroscopeGenerator horoscopeGenerator = AgenticServices.agentBuilder(HoroscopeGenerator.class)
.chatModel(baseModel())
.outputKey("horoscope")
.build();

PersonExtractor personExtractor = AgenticServices.agentBuilder(PersonExtractor.class)
.chatModel(baseModel())
.outputKey("person")
.build();

SignExtractor signExtractor = AgenticServices.agentBuilder(SignExtractor.class)
.chatModel(baseModel())
.outputKey("sign")
.build();

Writer writer = AgenticServices.agentBuilder(Writer.class)
.chatModel(baseModel())
.outputKey("writeup")
.build();

StoryFinder storyFinder = AgenticServices.agentBuilder(StoryFinder.class)
.chatModel(baseModel())
.tools(new WebSearchTool())
.outputKey("story")
.build();

UntypedAgent horoscopeAgent = AgenticServices.plannerBuilder()
.subAgents(horoscopeGenerator, personExtractor, signExtractor, writer, storyFinder)
.outputKey("writeup")
.planner(GoalOrientedPlanner::new)
.build();

如前所述,该 agentic 系统的总体目标是生成 writeup,它也是基于 GOAP 的 planner 自身的输出键。考虑所有子 agent 的输入和输出后,GoalOrientedSearchGraph 构建的依赖图如下所示:

当使用诸如“My name is Mario and my zodiac sign is pisces”的提示调用此 agentic 系统时:

Map<String, Object> input = Map.of("prompt", "My name is Mario and my zodiac sign is pisces");
String writeup = horoscopeAgent.invoke(input);

GoalOrientedPlanner 会分析仅包含 prompt 变量的 AgenticScope 初始状态,然后计算依赖图上从该初始状态到期望目标 writeup 的最短路径,因此得到的 agent 调用序列为:

Agents path sequence: [extractPerson, extractSign, horoscope, findStory, write]

请注意,如前所述,该目标导向 agentic 模式可以与任何其他现有 agentic 模式混合和组合。例如,这种能力可用于克服此方法的一个明显限制:由于它针对沿最短路径达成特定目标进行了优化,其结构上不允许循环,因此在某些情况下,将循环 agentic 模式作为此目标导向模式的子 agent 会很有用。

对等 agentic 模式

截至目前讨论的所有 agentic 系统都基于集中式、层级式架构。实际上,所有工作流模式都有定义明确的顶层 agent,以预先通过编程确定的方式协调多个子 agent 的活动。即使是因为存在基于 LLM 的 planner agent 而更灵活、动态的 supervisor 模式,也仍依赖协调 agent 来控制各子 agent 之间的交互。这类架构适用于许多应用和场景,但也可能存在一些限制,尤其是在可扩展性和容错性方面。因此,我们可能希望为多 agent 系统提供一种替代的对等方法,通过采用更去中心化和分布式的策略来克服这些限制。

在对等 agentic 系统中,没有顶层 agent,所有 agent 都是通过 AgenticScope 状态协调的平等对等方。具体而言,当 AgenticScope 中存在其必需输入对应的状态变量时,一个 agent 就会被触发。随后,由其他 agent 的输出所产生的这些变量中的一个或多个变量发生变化时,该 agent 可能再次被触发调用。当 AgenticScope 达到稳定状态且不再能调用任何 agent 时,或者满足预定义退出条件时,或者达到最大 agent 调用次数时,该过程终止。实现该对等 agentic 模式的 Planner 可按如下方式编写:

public class P2PPlanner implements Planner {

private final int maxAgentsInvocations;
private final BiPredicate<AgenticScope, Integer> exitCondition;

private int invocationCounter = 0;
private Map<String, AgentActivator> agentActivators;

public P2PPlanner(int maxAgentsInvocations, BiPredicate<AgenticScope, Integer> exitCondition) {
this(null, maxAgentsInvocations, exitCondition);
}

@Override
public void init(InitPlanningContext initPlanningContext) {
this.agentActivators = initPlanningContext.subagents().stream().collect(toMap(AgentInstance::agentId, AgentActivator::new));
}

@Override
public Action nextAction(PlanningContext planningContext) {
if (terminated(planningContext.agenticScope())) {
return done();
}

AgentActivator lastExecutedAgent = agentActivators.get(planningContext.previousAgentInvocation().agentId());
lastExecutedAgent.finishExecution();
agentActivators.values().forEach(a -> a.onStateChanged(lastExecutedAgent.agent.outputKey()));

return nextCallAction(planningContext.agenticScope());
}

private Action nextCallAction(AgenticScope agenticScope) {
AgentInstance[] agentsToCall = agentActivators.values().stream()
.filter(agentActivator -> agentActivator.canActivate(agenticScope))
.peek(AgentActivator::startExecution)
.map(AgentActivator::agent)
.toArray(AgentInstance[]::new);
invocationCounter += agentsToCall.length;
return call(agentsToCall);
}

private boolean terminated(AgenticScope agenticScope) {
return invocationCounter > maxAgentsInvocations || exitCondition.test(agenticScope, invocationCounter);
}
}

这里,P2PPlanner 会跟踪目前已执行的 agent 调用次数,并为每个子 agent 使用 AgentActivator,以根据 AgenticScope 的当前状态判断是否可以调用它。nextAction 方法检查是否已满足退出条件或是否达到最大调用次数;如果都不是,它会根据当前状态识别所有可激活的 agent,将它们标记为已开始,然后返回调用它们的动作。

为给出其工作方式的实际示例,让我们构建一个对等 agentic 系统:它能对指定主题开展科学研究并提出新假设,因此该服务的 API 可以如下所示:

public interface ResearchAgent {

@Agent("Conduct research on a given topic")
String research(@V("topic") String topic);
}

为此,可以定义以下五个 agent:

public interface LiteratureAgent {

@SystemMessage("Search for scientific literature on the given topic and return a summary of the findings.")
@UserMessage("""
You are a scientific literature search agent.
Your task is to find relevant scientific papers on the topic provided by the user and summarize them.
Use the provided tool to search for scientific papers and return a summary of your findings.
The topic is: {{topic}}
""")
@Agent("Search for scientific literature on a given topic")
String searchLiterature(@V("topic") String topic);
}

public interface HypothesisAgent {

@SystemMessage("Based on the research findings, formulate a clear and concise hypothesis related to the given topic.")
@UserMessage("""
You are a hypothesis formulation agent.
Your task is to formulate a clear and concise hypothesis based on the research findings provided by the user.
The topic is: {{topic}}
The research findings are: {{researchFindings}}
""")
@Agent("Formulate hypothesis around a give topic based on research findings")
String makeHypothesis(@V("topic") String topic, @V("researchFindings") String researchFindings);
}

public interface CriticAgent {

@SystemMessage("Critically evaluate the given hypothesis related to the specified topic. Provide constructive feedback and suggest improvements if necessary.")
@UserMessage("""
You are a critical evaluation agent.
Your task is to critically evaluate the hypothesis provided by the user in relation to the specified topic.
Provide constructive feedback and suggest improvements if necessary.
If you need to, you can also perform additional research to validate or confute the hypothesis using the provided tool.
The topic is: {{topic}}
The hypothesis is: {{hypothesis}}
""")
@Agent("Critically evaluate a hypothesis related to a given topic")
String criticHypothesis(@V("topic") String topic, @V("hypothesis") String hypothesis);
}

public interface ValidationAgent {

@SystemMessage("Validate the provided hypothesis on the given topic based on the critique provided.")
@UserMessage("""
You are a validation agent.
Your task is to validate the hypothesis provided by the user in relation to the specified topic based on the critique provided.
Validate the provided hypothesis, either confirming it or reformulating a different hypothesis based on the critique.
The topic is: {{topic}}
The hypothesis is: {{hypothesis}}
The critique is: {{critique}}
""")
@Agent("Validate a hypothesis based on a given topic and critique")
String validateHypothesis(@V("topic") String topic, @V("hypothesis") String hypothesis, @V("critique") String critique);
}

public interface ScorerAgent {

@SystemMessage("Score the provided hypothesis on the given topic based on the critique provided.")
@UserMessage("""
You are a scoring agent.
Your task is to score the hypothesis provided by the user in relation to the specified topic based on the critique provided.
Score the provided hypothesis on a scale from 0.0 to 1.0, where 0.0 means the hypothesis is completely invalid and 1.0 means the hypothesis is fully valid.
The topic is: {{topic}}
The hypothesis is: {{hypothesis}}
The critique is: {{critique}}
""")
@Agent("Score a hypothesis based on a given topic and critique")
double scoreHypothesis(@V("topic") String topic, @V("hypothesis") String hypothesis, @V("critique") String critique);
}

所有这些 agent 都将获得能够对科学文献开展研究的工具,例如从 arXiv 下载学术论文;随后将它们添加到 P2P agentic 系统中:

ArxivCrawler arxivCrawler = new ArxivCrawler();

LiteratureAgent literatureAgent = AgenticServices.agentBuilder(LiteratureAgent.class)
.chatModel(baseModel())
.tools(arxivCrawler)
.outputKey("researchFindings")
.build();
HypothesisAgent hypothesisAgent = AgenticServices.agentBuilder(HypothesisAgent.class)
.chatModel(baseModel())
.tools(arxivCrawler)
.outputKey("hypothesis")
.build();
CriticAgent criticAgent = AgenticServices.agentBuilder(CriticAgent.class)
.chatModel(baseModel())
.tools(arxivCrawler)
.outputKey("critique")
.build();
ValidationAgent validationAgent = AgenticServices.agentBuilder(ValidationAgent.class)
.chatModel(baseModel())
.tools(arxivCrawler)
.outputKey("hypothesis")
.build();
ScorerAgent scorerAgent = AgenticServices.agentBuilder(ScorerAgent.class)
.chatModel(baseModel())
.tools(arxivCrawler)
.outputKey("score")
.build();

ResearchAgent researcher = AgenticServices.plannerBuilder(ResearchAgent.class)
.subAgents(literatureAgent, hypothesisAgent, criticAgent, validationAgent, scorerAgent)
.outputKey("hypothesis")
.planner(() -> new P2PPlanner(10, agenticScope -> {
if (!agenticScope.hasState("score")) {
return false;
}
double score = agenticScope.readState("score", 0.0);
System.out.println("Current hypothesis score: " + score);
return score >= 0.85;
}))
.build();

String hypothesis = researcher.research("black holes");

在此配置中,向 researcher P2P 协调器传入研究主题。此时,唯一可调用的 agent 是 literatureAgent,因为只有它的全部必需输入(本例为 topic)存在于 AgenticScope 中。调用该 agent 会产生 researchFindings 变量,将其添加到 AgenticScope 状态;此新变量会触发 HypothesisAgent 的调用。接着它会产生 hypothesis,进而触发 criticAgent。最后,ValidationAgenthypothesiscritique 作为输入,生成新的 hypothesis,最终再次触发其他 agent。与此同时,ScorerAgent 会为 hypothesis 给出 score,当该 score 大于或等于 0.85,或已执行最多 10 次 agent 调用时,过程终止。下图汇总了此执行中涉及的所有 agent 和变量。

例如,此示例的一次典型运行可能因为 ScorerAgent 产生高于预定阈值的评分而终止:

Current hypothesis score: 0.95

最终输出可能如下:

Based on the provided references, here are some key points about stochastic gravitational wave backgrounds (SGWBs) from primordial black holes (PBHs):

1. **Detection Rates and Sources:**
- The detection rate of gravity waves emitted during parabolic encounters of stellar black holes in globular clusters was estimated by Kocsis et al. [85].
- Gravitational wave bursts from PBH hyperbolic encounters were discussed by García-Bellido and Nesseris [93].

2. **Energy Emission:**
- The energy spectrum of gravitational waves from hyperbolic encounters was studied by De Vittori, Jetzer, and Klein [88].
- Gravitational wave energy emission and detection rates for PBH hyperbolic encounters were analyzed by García-Bellido and Nesseris [90].

3. **Template Banks:**
- Template banks for gravitational waveforms from coalescing binary black holes (including non-spinning binaries) were developed by Ajith et al. [92].

4. **Constraints on PBHs:**
- Constraints on primordial black holes were reviewed by Carr, Kohri, Sendouda, and Yokoyama [98].
- Universal gravitational wave signatures of cosmological solitons were discussed by Lozanov, Sasaki, and Takhistov [100].

5. **Induced SGWBs:**
- Doubly peaked induced stochastic gravitational wave backgrounds were tested for baryogenesis from primordial black holes by Bhaumik et al. [101].
- Distinct signatures of spinning PBH domination and evaporation, including doubly peaked gravitational waves, dark relics, and CMB complementarity, were explored by Bhaumik et al. [101].

6. **Future Detectors:**
- Future detectors like Taiji, LISA, DECIGO, Big Bang Observer, Cosmic Explorer, Einstein Telescope, and KAGRA are expected to contribute significantly to the detection of SGWBs from PBHs.

7. **Pulsar Timing Arrays:**
- Pulsar timing arrays have been used to search for an isotropic stochastic gravitational wave background [73-75].

8. **Template Banks and Simulations:**
- Template banks like those developed by Ajith et al. are crucial for matching observed signals with theoretical predictions.

黑板 agentic 模式

P2P 模式会并行激活所有就绪 agent,并将它们视为平等对等方。然而,在某些场景中,集中式调度程序应决定下一个触发的单个 agent,并在多个 agent 均可贡献时应用冲突解决机制。这就是黑板模式:agent 是向 AgenticScope(黑板)发布部分结果的知识源,集中式 planner 会在每个步骤后检查黑板,以激活最合适的 agent。

与 P2P 一样,当 agent 的所有参数都存在于 scope 中时,它会被隐式激活。关键差异是每一步只会触发一个 agent;当多个 agent 就绪时,ConflictResolutionStrategy 决定哪个 agent 优先。如果未提供策略,则默认使用 subAgents 方法中的声明顺序作为决胜规则。

当满足目标谓词或没有 agent 可再触发(静止状态)时,BlackboardPlanner 成功终止;如果在目标满足前达到最大调用次数,它会抛出 IllegalStateException。默认情况下,目标谓词检查 planner 的 outputKey 是否存在于 scope 中——这是最常见的终止条件:

public class BlackboardPlanner implements Planner {

private final Predicate<AgenticScope> goalPredicate;
private final ConflictResolutionStrategy conflictResolutionStrategy;
private final int maxInvocations;

@Override
public Action nextAction(PlanningContext planningContext) {
// After each agent completes:
// 1. Check goal predicate → done() if satisfied
// 2. Find all agents whose inputs are available
// 3. Pick the best one via conflict resolution (or declaration order)
// 4. Return call(selectedAgent) — always exactly one agent per step
}
}

ConflictResolutionStrategy 是一个函数式接口,它接收当前 scope 和所有准备触发的候选 agent,并返回应激活的 agent。

@FunctionalInterface
public interface ConflictResolutionStrategy {

AgentInstance resolve(AgenticScope scope, List<AgentInstance> candidates);
}

该接口提供了一些便捷的工厂方法,以及用于将多个策略串联的 or 组合器。例如,declarationOrder() 仅选择第一个候选项,保留 subAgents 方法中使用的顺序;agentOfType 选择与给定类型匹配的候选项——可选择由 AgenticScope 条件保护,并在条件不满足或不存在该类型的候选项时返回 nullor 组合器串联两个策略:若第一个返回 null,则尝试第二个。它们共同让你构建诸如 agentOfType(X.class, condition).or(declarationOrder()) 的管道,其含义为:“当条件成立时优先选择 X 类型的 agent,否则回退到声明顺序”。

作为实际示例,考虑一个医疗诊断系统:专科 agent 会将发现发布到黑板,而只有累积了足够证据时才产生诊断。agent 的触发顺序并非预先确定,而取决于可用的数据:

SymptomExtractor symptomExtractor = AgenticServices.agentBuilder(SymptomExtractor.class)
.chatModel(baseModel()).build();

LabResultAnalyzer labAnalyzer = AgenticServices.agentBuilder(LabResultAnalyzer.class)
.chatModel(baseModel()).build();

DrugInteractionChecker drugInteraction = AgenticServices.agentBuilder(DrugInteractionChecker.class)
.chatModel(baseModel()).build();

DiagnosisAgent diagnosis = AgenticServices.agentBuilder(DiagnosisAgent.class)
.chatModel(baseModel()).build();

MedicalDiagnostics diagnostics = AgenticServices.plannerBuilder(MedicalDiagnostics.class)
.subAgents(symptomExtractor, labAnalyzer, drugInteraction, diagnosis)
.planner(BlackboardPlanner::new)
.outputKey("diagnosis")
.build();

String result = diagnostics.diagnose(patientInput, labResults, medications);

SymptomExtractor 首先触发,因为它唯一的输入(patientInput)从一开始就可用。症状被提取后,LabResultAnalyzerDrugInteractionChecker 都可能符合触发条件——但每一步只触发一个。最后,当黑板上同时存在 symptomslabAnalysis 时,DiagnosisAgent 被触发。系统终止,是因为默认目标谓词检测到 "diagnosis"(planner 的 outputKey)现在已存在于 scope 中。当终止条件更复杂时,可以向 BlackboardPlanner 构造函数提供自定义目标谓词。

当临床上下文会影响 agent 的排序时,ConflictResolutionStrategy 可以检查 scope 状态以做出明智决策。例如,如果患者症状提及药物或副作用,药物相互作用分析应优先于实验室分析。

MedicalDiagnostics diagnostics = AgenticServices.plannerBuilder(MedicalDiagnostics.class)
.subAgents(symptomExtractor, labAnalyzer, drugInteraction, diagnosis)
.planner(() -> new BlackboardPlanner(
agentOfType(DrugInteractionChecker.class, scope -> {
String symptoms = scope.readState("symptoms", "");
return symptoms.toLowerCase().contains("medication")
|| symptoms.toLowerCase().contains("drug");
})
.or(declarationOrder())))
.outputKey("diagnosis")
.build();

为丰富该示例,HumanInTheLoop agent 也可以直接作为知识源参与黑板。inputKey 方法声明人工审核者依赖的 scope 键,因此黑板仅在该数据可用时激活它。它的 outputKey 设置为 "symptoms"——当审核者拒绝某项诊断时,返回值会用附加信息覆盖症状,这自然会重新触发所有依赖症状的 agent:

HumanInTheLoop humanReview = AgenticServices.humanInTheLoopBuilder()
.description("Review the diagnosis and decide whether to approve or request additional analysis")
.outputKey("symptoms")
.inputKey(String.class, "diagnosis")
.responseProvider(scope -> {
String diagnosis = scope.readState("diagnosis", "");
String symptoms = scope.readState("symptoms", "");
if (!isAcceptable(diagnosis)) {
return symptoms + ". Patient also reports blurred vision.";
}
scope.writeState("approvedDiagnosis", diagnosis);
return symptoms;
})
.build();

MedicalDiagnostics diagnostics = AgenticServices.plannerBuilder(MedicalDiagnostics.class)
.subAgents(symptomExtractor, labAnalyzer, drugInteraction, diagnosisAgent, humanReview)
.planner(() -> new BlackboardPlanner(
scope -> scope.hasState("approvedDiagnosis"),
agentOfType(DrugInteractionChecker.class, scope -> {
String symptoms = scope.readState("symptoms", "");
return symptoms.toLowerCase().contains("medication")
|| symptoms.toLowerCase().contains("drug");
})
.or(declarationOrder())))
.outputKey("approvedDiagnosis")
.build();

inputKey(String.class, "diagnosis") 告诉黑板,审核者只能在 "diagnosis" 存在于 scope 后激活。当审核者拒绝时,返回值(增强后的症状)会通过 HITL 的 outputKey 写入 "symptoms" 键。黑板正常的 onStateChanged("symptoms") 机制随后会重新激活依赖症状的 agent(例如 DrugInteractionCheckerDiagnosisAgent),从而为下一次审核生成修订后的诊断。请注意,在第二个实现中,通过 BlackboardPlanner 实现的 MedicalDiagnostics 的 outputKey 已从 "diagnosis" 改为 "approvedDiagnosis",以便 HumanInTheLoop 有机会参与 agentic 系统的执行。这样,当审核者批准时,它会将 "approvedDiagnosis" 写入 AgenticScope,从而满足目标谓词。

投票 agentic 模式

到目前为止讨论的 agentic 模式都编排执行不同任务的 agent——它们分割工作、排列任务顺序或路由决策。然而,在某些情况下,你希望多个 agent 独立处理同一个问题,再汇总它们的答案以产生更稳健的结果。这就是投票(或委员会)模式:在相同输入上并行运行所有子 agent,将其输出收集为选票,再通过可插拔的聚合策略进行协调。

该模式特别适合分类、内容审核、风险评估以及任何多元方法共识比单个 agent 判断更可靠的决策。让具有不同提示、模型或视角的 agent 分析相同输入,可以减少单个 agent 的偏差或错误传播到最终结果的可能性。

VotingPlanner 会并行分派所有子 agent,等待它们全部完成,并使用 VotingStrategy 聚合其输出:

public class VotingPlanner implements Planner {

private final VotingStrategy strategy;

private List<AgentInstance> subagents;
private int completedCount;
private final List<Object> votes = new ArrayList<>();

public VotingPlanner() {
this(VotingStrategy.majority());
}

public VotingPlanner(VotingStrategy strategy) {
this.strategy = strategy;
}

@Override
public void init(InitPlanningContext initPlanningContext) {
this.subagents = initPlanningContext.subagents();
}

@Override
public Action firstAction(PlanningContext planningContext) {
if (subagents.isEmpty()) {
return done();
}
return call(subagents);
}

@Override
public Action nextAction(PlanningContext planningContext) {
votes.add(planningContext.previousAgentInvocation().output());
completedCount++;

if (completedCount < subagents.size()) {
return noOp();
}

return done(strategy.aggregate(votes));
}

@Override
public AgenticSystemTopology topology() {
return AgenticSystemTopology.STAR;
}
}

firstAction 方法一次分派所有子 agent。由于拓扑为 STAR,框架会并行执行它们,并为每个 agent 的完成调用一次 nextAction。每次调用都会将已完成 agent 的输出收集为一票。当所有 agent 完成投票后,planner 会通过 VotingStrategy 聚合结果并发出完成信号。

VotingStrategy 是一个包含单一方法和三个内置静态工厂方法的函数式接口:

@FunctionalInterface
public interface VotingStrategy {

Object aggregate(Collection<Object> votes);

static VotingStrategy majority() { ... } // most common value wins
static VotingStrategy average() { ... } // mean of numeric values
static VotingStrategy highest() { ... } // max by natural ordering
}
  • majority() 按相等性对选票分组并返回最常见的值——非常适合 agent 返回类别标签的分类任务。
  • average() 计算数值选票的算术平均值——适用于 agent 返回置信度或评分的打分任务。
  • highest() 按自然排序选取最大值——适用于希望获得最乐观或最高置信度评估的场景。

用户也可以通过 lambda 表达式提供自定义策略,例如实现加权投票、基于置信度的过滤或法定人数规则。

为给出其工作方式的实际示例,让我们构建一个基于投票的情感分类器,它使用三个具有不同提示的独立 agent 来分类客户反馈。每个 agent 都被指示只能返回一个词:POSITIVE、NEGATIVE 或 NEUTRAL。

public interface SentimentClassifier1 {

@UserMessage("""
Classify the sentiment of the following text.
Reply with exactly one word: POSITIVE, NEGATIVE, or NEUTRAL.
The text is: "{{text}}"
""")
@Agent("Classify the sentiment of a given text")
String classify(@V("text") String text);
}

public interface SentimentClassifier2 {

@UserMessage("""
You are a sentiment analysis expert.
Analyze the emotional tone of the following text and classify it.
Reply with exactly one word: POSITIVE, NEGATIVE, or NEUTRAL.
The text is: "{{text}}"
""")
@Agent("Analyze the emotional tone of a given text")
String classify(@V("text") String text);
}

public interface SentimentClassifier3 {

@UserMessage("""
You are a customer feedback analyst.
Determine whether the following feedback is positive, negative, or neutral.
Reply with exactly one word: POSITIVE, NEGATIVE, or NEUTRAL.
The text is: "{{text}}"
""")
@Agent("Determine the sentiment of customer feedback")
String classify(@V("text") String text);
}

每个分类器从略有不同的角度处理同一任务:一个是通用型分类器,一个是专家分析师,还有一个专注于客户反馈。这三个智能体随后可以组合成一个基于投票的智能体系统:

SentimentClassifier1 c1 = AgenticServices.agentBuilder(SentimentClassifier1.class)
.chatModel(baseModel())
.outputKey("vote1")
.build();

SentimentClassifier2 c2 = AgenticServices.agentBuilder(SentimentClassifier2.class)
.chatModel(baseModel())
.outputKey("vote2")
.build();

SentimentClassifier3 c3 = AgenticServices.agentBuilder(SentimentClassifier3.class)
.chatModel(baseModel())
.outputKey("vote3")
.build();

SentimentVoter voter = AgenticServices.plannerBuilder(SentimentVoter.class)
.subAgents(c1, c2, c3)
.outputKey("classification")
.planner(VotingPlanner::new)
.build();

ResultWithAgenticScope<String> result = voter.classify(
"I absolutely love this product! It exceeded all my expectations.");

使用此配置,三个分类器会针对同一文本并行调用。每个分类器将其分类结果写入各自的输出键(vote1vote2vote3),而 VotingPlanner 收集全部三个结果。由于默认构造函数使用 VotingStrategy.majority(),最常见的分类结果将胜出。对于上面明显积极的文本,三个智能体很可能都会返回 POSITIVE,从而得到一致结果。但即使在某个智能体持不同意见的模糊情形下,多数投票也能确保最终分类具有稳健性。

若要使用不同的聚合策略,只需将其传入 VotingPlanner 构造函数:

.planner(() -> new VotingPlanner(VotingStrategy.average()))

或者提供完全自定义的策略:

.planner(() -> new VotingPlanner(votes ->
votes.stream()
.map(Object::toString)
.map(String::toUpperCase)
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(null)))

此自定义策略会在计数前将所有投票规范化为大写,从而处理智能体间细微的格式差异(例如 "Positive""POSITIVE")。

辩论式智能体模式

到目前为止讨论的模式,要么一次性分派智能体(投票),要么根据数据可用性激活它们(黑板、P2P),要么将它们按目标依次编排(GOAP)。它们都不支持对抗式优化——即智能体能够查看彼此的推理,并迭代修正自身立场的过程。辩论模式填补了这一空白:智能体先并行生成独立答案,然后进入批评轮次,在其中可以阅读完整的辩论历史并完善论点。轮次会持续进行,直至智能体在同一答案上达成一致,或达到最大轮数;此时由裁判智能体作出最终裁决。

此模式特别适用于事实核查、风险评估、代码审查,以及任何让智能体接触相互竞争的论点能够提升输出质量的领域。通过迫使智能体面对并回应彼此的推理,辩论模式能够发现任何单个智能体可能遗漏的问题,并通过对抗性审查过滤误报。

此模式通过 DebatePlanner 实现,它将其子智能体划分为辩手(除最后一个外的所有智能体)和裁判(最后注册的子智能体)。该规划器配置有两个参数:maxRounds(默认值为 3)和 ConvergenceStrategy(默认值为 unanimous())。

初始化期间,规划器会验证至少已注册三个子智能体(两名辩手和一名裁判),并相应拆分列表。第一个操作会在 AgenticScope 中写入一个空的 debateContext 键——这是必需的,因为辩手智能体会通过 @V(DEBATE_CONTEXT_KEY) 引用该键;若没有它,第一轮将因 MissingArgumentException 失败。随后,它会并行分派所有辩手。

每当一名辩手完成时,都会调用 nextAction(该调用在框架锁下串行执行)。规划器将每个输出记录到一个以智能体名称为键的映射中,并在所有辩手完成前返回 noOp()。收集到全部响应后,它会检查两个条件:ConvergenceStrategy 是否报告已收敛,以及当前轮次是否已达到 maxRounds。只要任一条件成立,规划器便进入裁判阶段——它会将辩论上下文格式化为 AgentName: "response" 条目(每行一个),写入 scope,然后分派裁判智能体。裁判完成时,规划器使用裁判输出作为最终结果返回 done()。如果既未收敛也未达到轮数上限,规划器会将当前辩论上下文写入 scope,增加轮次计数器,并再次分派全部辩手进入另一轮批评;在该轮中,它们可以查看并回应彼此前一轮的论点。

ConvergenceStrategy 是一个函数式接口,只有一个方法 hasConverged(Collection<Object> positions)。提供了两种内置策略:unanimous() 检查所有立场是否完全相等(适用于 APPROVE/REJECT 等基于标签的决策);unanimousLastWord() 从每个立场中提取最后一个词,将其规范化为大写,并检查所有智能体是否以相同的结论结束。对于更细致的收敛逻辑——例如语义相似度或基于阈值的一致性——用户可以通过 lambda 提供自定义策略。

为观察此模式的实际运行,我们来构建一个辩论小组,其中三名伦理辩手从不同的哲学视角进行论证,裁判则将其论点综合为最终裁决:

public interface UtilitarianDebater {

@UserMessage("""
You are a utilitarian ethics debater. \
Consider the following question and argue from a utilitarian perspective, maximizing overall well-being.
If previous debate context is provided, consider the other debaters' arguments and refine your position.
Keep your response to 2-3 sentences. End with a one-word verdict: AGREE or DISAGREE.
Question: {{question}}
Previous debate context: {{debateContext}}
""")
@Agent(value = "Argues from a utilitarian ethics perspective", name = "Utilitarian")
String debate(@V("question") String question, @V(DEBATE_CONTEXT_KEY) String debateContext);
}

public interface DeontologicalDebater {

@UserMessage("""
You are a deontological ethics debater. \
Consider the following question and argue based on moral rules, duties, and rights.
If previous debate context is provided, consider the other debaters' arguments and refine your position.
Keep your response to 2-3 sentences. End with a one-word verdict: AGREE or DISAGREE.
Question: {{question}}
Previous debate context: {{debateContext}}
""")
@Agent(value = "Argues from a deontological ethics perspective", name = "Deontologist")
String debate(@V("question") String question, @V(DEBATE_CONTEXT_KEY) String debateContext);
}

public interface PragmatistDebater {

@UserMessage("""
You are a pragmatist debater. \
Consider the following question and argue based on practical consequences and real-world outcomes.
If previous debate context is provided, consider the other debaters' arguments and refine your position.
Keep your response to 2-3 sentences. End with a one-word verdict: AGREE or DISAGREE.
Question: {{question}}
Previous debate context: {{debateContext}}
""")
@Agent(value = "Argues from a pragmatist perspective", name = "Pragmatist")
String debate(@V("question") String question, @V(DEBATE_CONTEXT_KEY) String debateContext);
}

public interface EthicsJudge {

@UserMessage("""
You are an impartial ethics judge. \
Review the debate context where multiple debaters have argued about a question from different perspectives.
Synthesize their arguments and provide a balanced, well-reasoned final verdict in 3-4 sentences.
Debate context: {{debateContext}}
""")
@Agent(value = "Renders a final verdict by synthesizing debate arguments", name = "Judge")
String judge(@V("debateContext") String debateContext);
}

每名辩手都接收 question(保持不变的原始输入)和 debateContext(由规划器在每轮根据辩论历史更新)。@V(DEBATE_CONTEXT_KEY) 引用了 DebatePlanner 的公共常量,而每个 @Agent 上显式指定的 name 控制智能体在辩论上下文中的标签。裁判只接收 debateContext,因为原始问题已嵌入辩论交互中。请注意,每名辩手必须具有不同的 outputKey,以防止它们相互覆盖输出。

顶层规划器接口及其装配如下:

public interface EthicsPanel {

@Agent
String debate(@V("question") String question);
}

UtilitarianDebater d1 = AgenticServices.agentBuilder(UtilitarianDebater.class)
.chatModel(baseModel())
.outputKey("utilitarian")
.build();

DeontologicalDebater d2 = AgenticServices.agentBuilder(DeontologicalDebater.class)
.chatModel(baseModel())
.outputKey("deontological")
.build();

PragmatistDebater d3 = AgenticServices.agentBuilder(PragmatistDebater.class)
.chatModel(baseModel())
.outputKey("pragmatist")
.build();

EthicsJudge judge = AgenticServices.agentBuilder(EthicsJudge.class)
.chatModel(baseModel())
.outputKey("verdict")
.build();

EthicsPanel panel = AgenticServices.plannerBuilder(EthicsPanel.class)
.subAgents(d1, d2, d3, judge)
.outputKey("verdict")
.planner(() -> new DebatePlanner(3))
.build();

String result = panel.debate(
"Is it ethical to use AI-generated art in commercial products without crediting the AI tool?");

辩手列在前面,裁判始终是最后一个子智能体。在第 1 轮中,三名辩手都会提出独立立场。在第 2 轮中,每名辩手都能看到其他人的论点,并可完善、质疑或扩展自己的立场。无论辩手达成收敛还是达到最大轮数,裁判始终会根据完整辩论上下文作出最终裁决。

要自定义收敛检查或轮数:

.planner(() -> new DebatePlanner(5))  // allow up to 5 rounds
.planner(() -> new DebatePlanner(positions ->
positions.stream().allMatch(p -> p.toString().contains("AGREE")))) // custom convergence

信念-愿望-意图(BDI)智能体模式

信念-愿望-意图(BDI)模式建模了经典的 AI 概念:智能体维护明确目标,评估当前哪些目标可以实现,并在环境发生变化时在目标间做出反应式切换。实现该模式的规划器维护三种结构——信念(来自 AgenticScope 的当前世界状态)、愿望(一组具有优先级的目标)和意图(当前正在执行的既定计划)。每一步中,规划器都会检查是否有更高优先级的愿望变得可实现;若是,则放弃当前意图并重新审议。这使 BDI 天然适合于必须平衡多个相互竞争目标、且优先级可能随时变化的动态环境。

Desire 被定义为一个 record,组合了名称、优先级、可实现性谓词、满足性谓词,以及构成为实现该愿望意图的有序智能体类型列表:

public record Desire(String name, int priority,
Predicate<AgenticScope> achievable,
Predicate<AgenticScope> satisfied,
List<Class<?>> agentTypes) {

public static Desire of(String name, int priority,
Predicate<AgenticScope> achievable,
Predicate<AgenticScope> satisfied,
Class<?>... agentTypes) {
return new Desire(name, priority, achievable, satisfied, List.of(agentTypes));
}

public static Desire of(String name, int priority,
String achievableStateKey,
String satisfiedStateKey,
Class<?>... agentTypes) {
return new Desire(name, priority,
scope -> scope.hasState(achievableStateKey),
scope -> scope.hasState(satisfiedStateKey),
List.of(agentTypes));
}
}

实现此模式的 BDIPlanner 接收一个 Desire 实例列表,并实现审议循环。初始化期间,它会按类型映射每个已注册的子智能体,以便愿望可以按类引用智能体。执行开始后,规划器筛选所有愿望,找出当前可实现且尚未满足的愿望,选择优先级最高的一个(优先级相同时,列表中先声明的获胜),并提交其意图,即该愿望定义的有序智能体序列。在每个后续步骤中,规划器会执行三项检查:第一,满足性,测试当前愿望是否已满足,若已满足,规划器将重新审议以选择下一个愿望;第二,抢占,验证是否由于信念变化(写入 AgenticScope 的新值)而有严格更高优先级的愿望变得可实现,当前意图将被挂起并由更高优先级的意图接管;第三,可行性,检查当前愿望是否仍可实现且未满足,规划器会推进到意图序列中的下一个智能体。当先前被抢占的愿望之后再次被选中时,它会从中断位置继续,而非重新开始,因此已经完成的智能体不会再次被调用。

当所有愿望均已满足,或没有任何愿望可实现时,执行将成功终止。规划器会在两种异常行为场景下抛出 IllegalStateException:某个愿望的整个意图执行完毕但该愿望仍未满足(智能体未写入满足谓词所期望的键),或在仍有未满足愿望待处理时达到可配置的最大调用次数。

在崩溃恢复时,规划器会从头重新审议:已满足的愿望会被跳过,但选中愿望的意图会从第一个智能体重新开始。崩溃前已经完成的智能体会再次运行,因此意图中的智能体应当是幂等的。

为说明此模式,考虑一个包含五个 AI 智能体和一个非 AI 智能体的自主交易系统。MarketRecommendationAgent 返回 MarketRecommendation 枚举,并且只有当建议为 SELLSTRONG_SELL 时才会触发对冲。HedgingStrategyDefaulter 是一个非 AI 智能体,它确保 hedgingStrategy 始终存在于 scope 中(跳过对冲时默认设为 "None"),因此 RebalancingAgent 始终可以将其作为输入接收:

public enum MarketRecommendation {
STRONG_BUY, BUY, HOLD, SELL, STRONG_SELL
}

public interface MarketAnalysisAgent {
@UserMessage("Analyze the market data and portfolio. Market: {{marketData}} Portfolio: {{portfolio}}")
@Agent(value = "Analyze market conditions", outputKey = "marketAnalysis")
String analyzeMarket(@V("marketData") String marketData, @V("portfolio") String portfolio);
}

public interface MarketRecommendationAgent {
@UserMessage("Based on the market analysis, provide a trading recommendation. Market analysis: {{marketAnalysis}}")
@Agent(value = "Provide a trading recommendation", outputKey = "recommendation")
MarketRecommendation recommend(@V("marketAnalysis") String marketAnalysis);
}

public static class HedgingStrategyDefaulter {
@Agent(outputKey = "hedgingStrategy")
public String defaultHedging(AgenticScope scope) {
return scope.hasState("hedgingStrategy") ? (String) scope.readState("hedgingStrategy") : "None";
}
}

public interface RebalancingAgent {
@UserMessage("Suggest rebalancing based on: {{marketAnalysis}} Hedging strategy: {{hedgingStrategy}} Portfolio: {{portfolio}}")
@Agent(value = "Rebalance portfolio", outputKey = "rebalancingPlan")
String rebalance(@V("marketAnalysis") String marketAnalysis,
@V("hedgingStrategy") String hedgingStrategy,
@V("portfolio") String portfolio);
}

public interface HedgingAgent {
@UserMessage("Recommend hedging strategies based on: {{marketAnalysis}}")
@Agent(value = "Hedge against risks", outputKey = "hedgingStrategy")
String hedge(@V("marketAnalysis") String marketAnalysis);
}

public interface LiquidityAgent {
@UserMessage("Assess liquidity for portfolio: {{portfolio}}")
@Agent(value = "Maintain liquidity", outputKey = "liquidityAssessment")
String assessLiquidity(@V("portfolio") String portfolio);
}

这些智能体被装配为一个基于 BDI 的交易系统,其中包含四个优先级不同的愿望。请注意,“对冲风险”愿望使用基于谓词的可实现性检查来检查建议值,而“再平衡投资组合”愿望在 RebalancingAgent 之前包含 HedgingStrategyDefaulter,以确保 hedgingStrategy scope 值存在:

TradingSystem tradingSystem = AgenticServices.plannerBuilder(TradingSystem.class)
.subAgents(marketAnalysis, recommendation, new HedgingStrategyDefaulter(),
rebalancing, hedging, liquidity)
.planner(() -> new BDIPlanner(List.of(
Desire.of("analyze market", 1,
"marketData", "recommendation",
MarketAnalysisAgent.class, MarketRecommendationAgent.class),
Desire.of("hedge risks", 2,
scope -> scope.hasState("recommendation")
&& Set.of(MarketRecommendation.SELL, MarketRecommendation.STRONG_SELL)
.contains(scope.readState("recommendation")),
scope -> scope.hasState("hedgingStrategy"),
HedgingAgent.class),
Desire.of("rebalance portfolio", 1,
"recommendation", "rebalancingPlan",
HedgingStrategyDefaulter.class, RebalancingAgent.class),
Desire.of("maintain liquidity", 1,
"portfolio", "liquidityAssessment",
LiquidityAgent.class)
)))
.build();

使用市场数据和投资组合状态调用时,规划器的审议循环按如下方式工作:“分析市场”和“维持流动性”愿望最初均可实现。MarketAnalysisAgentMarketRecommendationAgent 完成后,建议结果决定下一步。如果建议为 SELLSTRONG_SELL,“对冲风险”愿望(优先级为 2)便变得可实现,并抢占任何低优先级工作,使规划器调用 HedgingAgent。对冲完成后,规划器重新审议:“再平衡投资组合”愿望会先运行 HedgingStrategyDefaulter(它保留已有的对冲策略),随后运行 RebalancingAgent,后者接收对冲策略作为输入。如果建议不是 SELLSTRONG_SELL,则完全跳过对冲,HedgingStrategyDefaulter 会写入 "None",使 RebalancingAgent 仍可继续执行。这种由条件驱动的反应式切换正是 BDI 的本质——系统根据变化的信念调整行为,而不是遵循僵化的计划。

非 AI 智能体

到目前为止讨论的所有智能体都是 AI 智能体,即它们基于 LLM,可以被调用来执行需要自然语言理解和生成的任务。然而,langchain4j-agentic 模块也支持非 AI 智能体,可用于执行不需要自然语言处理的任务,例如调用 REST API 或执行命令。这些非 AI 智能体确实更接近工具,但在此上下文中,将它们建模为智能体很方便,使其能够与 AI 智能体以相同方式使用,并与之混合以组成更强大、更完整的智能体系统。

例如,监督者示例中使用的 ExchangeAgent 很可能被不恰当地建模成了 AI 智能体;将其更好地定义为仅调用 REST API 执行货币兑换的非 AI 智能体会更合适。

public class ExchangeOperator {

@Agent(value = "A money exchanger that converts a given amount of money from the original to the target currency",
outputKey = "exchange")
public Double exchange(@V("originalCurrency") String originalCurrency, @V("amount") Double amount, @V("targetCurrency") String targetCurrency) {
// invoke the REST API to perform the currency exchange
}
}

这样它就可以与提供给监督者的其他子智能体以相同方式使用。

WithdrawAgent withdrawAgent = AgenticServices
.agentBuilder(WithdrawAgent.class)
.chatModel(BASE_MODEL)
.tools(bankTool)
.build();
CreditAgent creditAgent = AgenticServices
.agentBuilder(CreditAgent.class)
.chatModel(BASE_MODEL)
.tools(bankTool)
.build();

SupervisorAgent bankSupervisor = AgenticServices
.supervisorBuilder()
.chatModel(PLANNER_MODEL)
.subAgents(withdrawAgent, creditAgent, new ExchangeOperator())
.build();

本质上,langchain4j-agentic 中的智能体可以是任何 Java 类,只要它恰好有一个使用 @Agent 注解标注的方法。

最后,非 AI 智能体还可用于读取 AgenticScope 的状态,或在其上执行小型操作。因此,AgenticServices 提供了 agentAction 工厂方法,可从 Consumer<AgenticServices> 创建简单智能体。例如,假设有一个 scorer 智能体生成作为 String 值的 score,而后续的 reviewer 智能体需要将该 score 作为 double 使用。在这种情况下,两个智能体并不兼容,但可以使用 agentAction 按第二个智能体所需格式适配第一个智能体的输出,按如下方式重写 AgenticScopescore 状态:

UntypedAgent editor = AgenticServices.sequenceBuilder()
.subAgents(
scorer,
AgenticServices.agentAction(agenticScope -> agenticScope.writeState("score", Double.parseDouble(agenticScope.readState("score", "0.0")))),
reviewer)
.build();

人在回路

构建智能体系统时,另一个常见需求是让人参与回路,使系统能够在执行某些操作前向用户请求缺失的信息或批准。这种人在回路能力也可视为一种特殊的非 AI 智能体,并据此实现。

public record HumanInTheLoop(Function<AgenticScope, ?> responseProvider) {

@Agent("An agent that asks the user for missing information")
public Object askUser(AgenticScope scope) {
return responseProvider.apply(scope);
}
}

这个相当朴素但也非常通用的实现,基于一个函数:它接收当前 AgenticScope 作为输入,可从中提取上下文以提出恰当问题,并返回应提供给用户的响应。

langchain4j-agentic 模块开箱即用提供的 HumanInTheLoop 智能体允许你定义该函数、智能体描述,以及写入用户响应的输出变量。

例如,已定义如下 AstrologyAgent

public interface AstrologyAgent {
@SystemMessage("""
You are an astrologist that generates horoscopes based on the user's name and zodiac sign.
""")
@UserMessage("""
Generate the horoscope for {{name}} who is a {{sign}}.
""")
@Agent("An astrologist that generates horoscopes based on the user's name and zodiac sign.")
String horoscope(@V("name") String name, @V("sign") String sign);
}

可以创建一个顺序工作流,同时使用此 AI 智能体和 HumanInTheLoop 智能体,在生成星座运势前询问用户的星座:将问题发送到控制台标准输出,并从标准输入读取用户响应,如下所示:

HumanInTheLoop humanInTheLoop = AgenticServices.humanInTheLoopBuilder()
.description("An agent that asks the zodiac sign of the user")
.outputKey("sign")
.responseProvider(scope -> {
System.out.println("Hi " + scope.readState("name") + ", what is your sign?");
System.out.print("> ");
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
return reader.readLine();
} catch (IOException e) {
throw new RuntimeException("Failed to read input", e);
}
})
.build();

AstrologyAgent astrologyAgent = AgenticServices.agentBuilder(AstrologyAgent.class)
.chatModel(baseModel())
.outputKey("horoscope")
.build();

UntypedAgent horoscopeAgent = AgenticServices.sequenceBuilder()
.subAgents(humanInTheLoop, astrologyAgent)
.outputKey("horoscope")
.build();

这样,当用户使用如下请求调用 horoscopeAgent

horoscopeAgent.invoke(Map.of("name", "Mario"));

该顺序会先调用 HumanInTheLoop 智能体,询问用户缺失的星座信息,并产生以下输出:

Hi Mario, what is your sign?
>

等待用户提供答案,之后将使用该答案调用 AstrologyAgent 并生成星座运势。

由于用户可能需要一些时间才能提供答案,因此可以——实际上也建议——将 HumanInTheLoop 智能体配置为异步。这样,在智能体系统等待用户给出答案时,不需要用户输入的智能体可以继续执行。

记忆与上下文工程

到目前为止讨论的所有智能体都是无状态的,这意味着它们不维护先前交互的任何上下文或记忆。不过,与其他任何 AI 服务一样,可以向智能体提供 ChatMemory,使它们能够跨多次调用保持上下文。

要为前述 MedicalExpert 提供记忆,只需在其签名中添加一个以 @MemoryId 注解标注的字段。

public interface MedicalExpertWithMemory {

@UserMessage("""
You are a medical expert.
Analyze the following user request under a medical point of view and provide the best possible answer.
The user request is {{request}}.
""")
@Agent("A medical expert")
String medical(@MemoryId String memoryId, @V("request") String request);
}

并在构建智能体时设置记忆提供程序:

MedicalExpertWithMemory medicalExpert = AgenticServices
.agentBuilder(MedicalExpertWithMemory.class)
.chatModel(BASE_MODEL)
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))
.outputKey("response")
.build();

通常,这对于单独使用的单个智能体已足够,但对于参与智能体系统的智能体可能有所限制。假设技术和法律专家也已配备记忆,并且 ExpertRouterAgent 也被重新定义以具备记忆:

public interface ExpertRouterAgentWithMemory {

@Agent
String ask(@MemoryId String memoryId, @V("request") String request);
}

对该智能体进行如下两次调用的顺序

String response1 = expertRouterAgent.ask("1", "I broke my leg, what should I do?");

String legalResponse1 = expertRouterAgent.ask("1", "Should I sue my neighbor who caused this damage?");

将无法得到预期结果,因为第二个问题会被路由到法律专家;该专家此时是首次被调用,并不记得之前的问题。

要解决此问题,需要向法律专家提供上下文以及调用它之前发生的情况;这正是 AgenticScope 自动存储的信息能够发挥作用的另一个用例。

具体而言,AgenticScope 会跟踪所有智能体的调用序列,并能将这些调用串联为单个对话来生成上下文。该上下文可以直接使用,或在必要时压缩为较短版本,例如定义一个 ContextSummarizer 智能体。

public interface ContextSummarizer {

@UserMessage("""
Create a very short summary, 2 sentences at most, of the
following conversation between an AI agent and a user.

The user conversation is: '{{it}}'.
""")
String summarize(String conversation);
}

使用此智能体,可以重新定义法律专家,并为其提供前面对话的上下文摘要,使其在回答新问题时能够考虑先前交互。

LegalExpertWithMemory legalExpert = AgenticServices
.agentBuilder(LegalExpertWithMemory.class)
.chatModel(BASE_MODEL)
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))
.context(agenticScope -> contextSummarizer.summarize(agenticScope.contextAsConversation()))
.outputKey("response")
.build();

更一般地说,提供给智能体的上下文可以是 AgenticScope 状态的任意函数。采用此设置后,当被问及是否应起诉邻居造成的损害时,法律专家能够考虑与医疗专家先前的对话,并给出更充分的答案。

在内部,智能体框架通过自动改写发送给法律专家的用户消息来提供额外上下文,使其包含先前对话的摘要上下文;因此,在此情况下,实际用户消息将类似于:

"Considering this context \"The user asked about what to do after breaking their leg, and the AI provided medical advice on immediate actions like immobilizing the leg, applying ice, and seeking medical attention.\"
You are a legal expert.
Analyze the following user request under a legal point of view and provide the best possible answer.
The user request is Should I sue my neighbor who caused this damage?."

此处作为智能体可能的上下文生成示例讨论的摘要上下文具有普遍实用性,因此可以通过更便捷的方式在智能体上定义它,即使用 summarizedContext 方法,如下所示:

LegalExpertWithMemory legalExpert = AgenticServices
.agentBuilder(LegalExpertWithMemory.class)
.chatModel(BASE_MODEL)
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))
.summarizedContext("medical", "technical")
.outputKey("response")
.build();

这样做时,内部会使用前面讨论的 ContextSummarizer 智能体,并以定义该方法的智能体所使用的相同聊天模型执行它。还可以向此方法添加需要汇总其上下文的智能体名称可变参数,使摘要只针对这些智能体生成,而不是针对智能体系统中使用的全部智能体。

AgenticScope 注册表与持久化

AgenticScope 是在智能体系统执行期间创建和使用的临时数据结构。每个智能体系统中,每个用户对应一个 AgenticScope。对于不使用记忆的无状态执行,执行结束时 AgenticScope 会自动丢弃,其状态不会持久化到任何位置。

相反,当智能体系统使用记忆时,AgenticScope 会保存到内部注册表中。在这种情况下,AgenticScope 会永久保留在注册表中,以便用户能够以有状态、对话式的方式与智能体系统交互。因此,当具有特定 ID 的 AgenticScope 不再需要时,必须从注册表中显式逐出它。为此,智能体系统的根智能体需要实现 AgenticScopeAccess 接口,以便可以在其上调用 evictAgenticScope 方法,并传入需要从注册表移除的 AgenticScope ID。

agent.evictAgenticScope(memoryId);

AgenticScope 和其注册表都完全是内存中的数据结构。这通常足以满足简单智能体系统的需求,但在某些情况下,将 AgenticScope 状态持久化到更持久的存储中(如数据库或文件系统)会很有用。为实现这一点,langchain4j-agentic 模块提供了一个 SPI,用于接入实现 AgenticScopeStore 接口的自定义持久化层。可以通过编程方式设置该持久化层:

AgenticScopePersister.setStore(new MyAgenticScopeStore());

或使用标准 Java Service Provider 接口,创建名为 META-INF/services/dev.langchain4j.agentic.scope.AgenticScopeStore 的文件,其中包含实现 AgenticScopeStore 接口的类的完全限定名。

AgenticScope JSON 序列化

LangChain4j 通过 AgenticScopeSerializer 类为 AgenticScope 提供内置 JSON 序列化。出于安全考虑,反序列化采用允许列表策略,限制可从 JSON 反序列化的类。默认允许标准 JDK 类型(java.util.*java.math.*、基本类型包装器、枚举)以及 LangChain4j 内部类型(AgentMessageAgentInvocation)。

如果你的智能体在 AgenticScope 状态中存储了自定义领域对象,必须在反序列化发生前注册这些对象。你可以注册单个类:

AgenticScopeSerializer.allowDeserializationType(LoanApplication.class);

或注册整个包前缀:

AgenticScopeSerializer.allowDeserializationPackagePrefix("com.acme.myapp.");

尝试反序列化未注册的类型会抛出 UnserializableAgenticScopeException;其消息会指出被拒绝的类,并说明如何注册它。

AgenticScope 与智能体系统的可恢复性

配置 AgenticScopeStore 后,langchain4j-agentic 模块提供内置的可恢复性支持,使智能体系统可以在崩溃或进程重启后从中断处继续执行。这对于包含人在回路步骤的长时间运行智能体系统尤其有价值,因为进程可能被有意停止并在稍后重启。

可恢复性基于两个协作机制:逐步骤检查点规划器执行状态持久化

每次智能体调用后,当前 AgenticScope 都会自动检查点保存到已配置的存储中。这意味着智能体写入的所有中间状态(通过 writeState)都会被持久化保存。此外,执行循环还会保存规划器的内部位置(例如序列中已到达哪个智能体),以便恢复时工作流从正确步骤继续,而不是从头开始。

Planner 接口的实现可以通过两个方法选择性地参与此机制:

// Returns the planner's current internal state for persistence
default Map<String, Object> executionState() { return Map.of(); }

// Restores internal state from a previously saved map
default void restoreExecutionState(Map<String, Object> state) { }

例如,顺序规划器和循环规划器等有状态规划器实现这些方法,以保存和恢复其游标位置及迭代计数器。无状态规划器(如 ParallelPlannerConditionalPlanner)使用默认的空操作实现。自定义 Planner 实现也可重写这些方法以参与可恢复性。执行循环还会跟踪自身内部状态(例如并行块中哪些智能体已完成),并将其与规划器状态一同保存,因此恢复时只会重新调度尚未完成的智能体。

为说明其实际工作方式,设想一个订单处理工作流:大额订单在履行前必须由人工审核。该工作流包含三个步骤:验证订单、等待人工批准和发货。

public interface OrderWorkflow extends AgenticScopeAccess {
@Agent
String processOrder(@MemoryId String orderId, @V("order") String orderDetails);
}

@MemoryId 注解至关重要——它会启用持久化作用域,这是可恢复性所必需的。将工作流构建为由三个智能体组成的序列:

// Step 1: Validate the order and write results to shared state
AgenticScopeAction validateOrder = AgenticServices.agentAction(scope -> {
String order = scope.readState("order", "");
scope.writeState("validated_order", "VALIDATED: " + order);
});

// Step 2: Pause for human approval using SuspendedResponse
HumanInTheLoop approvalGate = AgenticServices.humanInTheLoopBuilder()
.description("Wait for manager approval on large orders")
.outputKey("approval")
.responseProvider(scope -> new SuspendedResponse<>("manager-approval"))
.build();

// Step 3: Finalize based on the approval decision
AgenticScopeAction shipOrder = AgenticServices.agentAction(scope -> {
String validated = scope.readState("validated_order", "");
String approval = scope.readState("approval", "");
scope.writeState("result", "Order " + validated + " — " + approval);
});

OrderWorkflow workflow = AgenticServices.sequenceBuilder(OrderWorkflow.class)
.subAgents(validateOrder, approvalGate, shipOrder)
.outputKey("result")
.build();

运行此工作流时,它会先验证订单,然后到达 HumanInTheLoop 步骤。由于响应提供者返回 SuspendedResponse,智能体系统会通过抛出 AgenticSystemSuspendedException 来暂停执行,而不是阻塞调用线程。完整作用域——包括已验证的订单数据、规划器游标位置(第 2 步已完成)以及 SuspendedResponse——会被检查点保存到存储中(如果已配置),并抛出 AgenticSystemSuspendedException 以释放线程:

try {
String result = workflow.processOrder("order-12345", "1000 widgets");
// Workflow completed normally
} catch (AgenticSystemSuspendedException e) {
// Workflow suspended — waiting for human input
AgenticScope scope = e.scope();
Set<String> pendingIds = scope.pendingResponseIds(); // → ["manager-approval"]
// Store the scope/pendingIds for your UI / REST API to present to the human
}

除了 SuspendedResponse 外,人在回路还可以返回 PendingResponse 类的实例,以阻塞调用线程,直到提供人工响应。概括而言:

响应类型行为
SuspendedResponse暂停智能体系统:检查点保存作用域,抛出 AgenticSystemSuspendedException,并释放调用线程。通过完成响应并再次调用智能体方法来恢复系统。
PendingResponse在底层 CompletableFuture阻塞调用线程,直至另一线程调用 complete()。不会抛出异常——智能体系统在原地等待。

用户可以在创建响应的位置做出此选择——即在 responseProvider lambda 或 @HumanInTheLoop 静态方法中:

// Suspension: the agentic system checkpoints and throws AgenticSystemSuspendedException
.responseProvider(scope -> new SuspendedResponse<>("approval-id"))

// Blocking: the calling thread waits until complete() is called from another thread
.responseProvider(scope -> new PendingResponse<>("approval-id"))

建议将 SuspendedResponse 用于重视崩溃恢复能力的长时间交互(数小时/数天),将 PendingResponse 用于后台线程很快会提供答案的短暂进程内等待。

如果方法的返回类型为 ResultWithAgenticScope,暂停时不会抛出异常;而是结果的 suspended() == true,且 result() == null。之后可以在一次调用中完成待处理响应并恢复执行:

ResultWithAgenticScope<String> result = workflow.processOrder("order-12345", "1000 widgets");
if (result.suspended()) {
result = result.completePendingResponse("APPROVED by manager");
// result.result() → "Order VALIDATED: 1000 widgets — APPROVED by manager"
}

这与包含多个顺序 HITL 关卡的多步骤工作流自然契合——每次 completePendingResponse 调用都会返回一个新的 ResultWithAgenticScope,它本身也可能处于暂停状态:

ResultWithAgenticScope<String> result = workflow.processOrder("order-12345", "1000 widgets");

result = result.completePendingResponse("Manager OK"); // resumes, suspends at legal gate
result = result.completePendingResponse("Legal OK"); // resumes, completes
// result.result() → final output

推荐使用 ResultWithAgenticScope 处理暂停,因为它避免了将异常用作控制流。

相反,如果方法返回普通类型(例如 String)而不是 ResultWithAgenticScope,系统会在暂停时抛出 AgenticSystemSuspendedException。在这种情况下,或者当需要直接通过作用域恢复时(例如崩溃/重启之后),可以在 AgenticScope 上完成响应并再次调用智能体方法:

AgenticScope scope = workflow.getAgenticScope("order-12345");

// Complete the single deferred response (when there is exactly one)
scope.completePendingResponse("APPROVED by manager");

// Or complete by explicit ID (useful when multiple responses are pending)
scope.completePendingResponse("manager-approval", "APPROVED by manager");

// Then re-invoke — the planner resumes from the checkpoint
String result = workflow.processOrder("order-12345", "1000 widgets");

请注意,completePendingResponse 既会完成内存中的 future(从而解除所有等待线程的阻塞),又会用已解析的值替换状态映射条目(使其可经受序列化)。如果不存在恰好一个延迟响应,单参数重载会抛出 IllegalStateException

智能体注册表

langchain4j-agentic 模块为 AgentsRegistry 提供 SPI(服务提供者接口),允许外部提供者注册可按名称发现并接入任意智能体模式的智能体。这对于将外部系统提供的智能体(如远程 A2A 协议智能体)集成到本地定义的智能体工作流中特别有用。

AgentsRegistry 接口定义了两种发现智能体的方法:

public interface AgentsRegistry {

Map<String, AgentInstance> allAgents();

AgentInstance getAgent(String name);

static AgentsRegistry get() { ... }
}
  • allAgents() 返回包含所有已注册智能体的映射,并以其名称作为键。
  • getAgent(String name) 返回指定名称的智能体;若未找到,则抛出 RuntimeException

静态 get() 方法使用 Java 的 ServiceLoader 发现注册表实现。支持多个提供者,并会自动合并为一个复合注册表;跨提供者出现重复的智能体名称会在发现时导致异常。如果未找到提供者,则返回一个空注册表,对任何查找操作都会抛出异常。

要提供注册表实现,请创建一个实现 AgentsRegistry 接口的类,并通过标准 Java SPI 机制注册它:创建文件 META-INF/services/dev.langchain4j.agentic.planner.AgentsRegistry,其中包含实现类的完全限定名。

public class MyAgentsRegistry implements AgentsRegistry {

private final Map<String, AgentInstance> agents;

public MyAgentsRegistry() {
AgentInstance audienceEditor = (AgentInstance) AgenticServices
.agentBuilder(AudienceEditor.class)
.chatModel(myModel())
.outputKey("story")
.build();

AgentInstance styleEditor = (AgentInstance) AgenticServices
.agentBuilder(StyleEditor.class)
.chatModel(myModel())
.outputKey("story")
.build();

this.agents = Map.of(
"audienceEditor", audienceEditor,
"styleEditor", styleEditor);
}

@Override
public Map<String, AgentInstance> allAgents() {
return agents;
}

@Override
public AgentInstance getAgent(String name) {
AgentInstance agent = agents.get(name);
if (agent == null) {
throw new RuntimeException("No agent found with name: " + name);
}
return agent;
}
}

注册表返回的智能体是标准 AgentInstance 对象,通常使用 AgenticServices.agentBuilder() 构建,因此可直接作为子智能体用于任意智能体模式。

注册表可通过 SPI 获得后,便可在任何智能体模式中加载智能体,并将其与本地定义的智能体混合使用:

AgentsRegistry registry = AgentsRegistry.get();

CreativeWriter creativeWriter = AgenticServices
.agentBuilder(CreativeWriter.class)
.chatModel(BASE_MODEL)
.outputKey("story")
.build();

AgentInstance audienceEditor = registry.getAgent("audienceEditor");
AgentInstance styleEditor = registry.getAgent("styleEditor");

UntypedAgent novelCreator = AgenticServices.sequenceBuilder()
.subAgents(creativeWriter, audienceEditor, styleEditor)
.outputKey("story")
.build();

String story = (String) novelCreator.invoke(Map.of(
"topic", "dragons and wizards",
"style", "fantasy",
"audience", "young adults"));

这里,CreativeWriter 在本地构建,而 AudienceEditorStyleEditor 则按名称从注册表加载,并与本地定义的智能体一起作为子智能体使用。

@RegistryAgent 注解允许在声明式 API 中从注册表加载智能体。要使用它,定义一个简单接口,其方法使用 @RegistryAgent 注解,并指定注册表中智能体的名称:

public interface AudienceEditorFromRegistry {
@RegistryAgent("audienceEditor")
String editStory(@V("story") String story, @V("audience") String audience);
}

public interface StyleEditorFromRegistry {
@RegistryAgent("styleEditor")
String editStory(@V("story") String story, @V("style") String style);
}

随后,这些接口可以在完全声明式的智能体系统定义中作为子智能体使用:

public interface DeclarativeStoryCreator {

@SequenceAgent(outputKey = "story",
subAgents = {CreativeWriter.class, AudienceEditorFromRegistry.class, StyleEditorFromRegistry.class})
String write(@V("topic") String topic, @V("style") String style, @V("audience") String audience);

@ChatModelSupplier
static ChatModel chatModel() {
return BASE_MODEL;
}
}

并照常使用 AgenticServices.createAgenticSystem() 实例化:

DeclarativeStoryCreator storyCreator = AgenticServices.createAgenticSystem(DeclarativeStoryCreator.class);

String story = storyCreator.write("dragons and wizards", "fantasy", "young adults");

当声明式系统遇到 @RegistryAgent 注解时,会按名称从注册表自动加载相应智能体,并将其接入智能体系统。这使得本地定义的智能体(如具有 @ChatModelSupplierCreativeWriter)与注册表提供的智能体可以混合用于同一声明式工作流。

A2A 集成

附加的 langchain4j-agentic-a2a 模块与 A2A 协议无缝集成,可构建能够使用远程 A2A 服务器智能体的智能体系统,并最终将它们与其他本地定义的智能体混合使用。

例如,如果第一个示例中的 CreativeWriter 智能体定义在远程 A2A 服务器上,则可以创建一个 A2ACreativeWriter 智能体,它的使用方式与本地智能体相同,但会调用远程智能体。

UntypedAgent creativeWriter = AgenticServices
.a2aBuilder(A2A_SERVER_URL)
.inputKeys("topic")
.outputKey("story")
.build();

智能体能力描述会从 A2A 服务器提供的智能体卡片中自动获取。但该卡片未提供输入参数名称,因此必须使用 inputKeys 方法显式指定。

或者,也可以为 A2A 智能体定义如下本地接口:

public interface A2ACreativeWriter {

@Agent
String generateStory(@V("topic") String topic);
}

这样便可更具类型安全地使用它,且输入名称会自动从方法参数派生。

A2ACreativeWriter creativeWriter = AgenticServices
.a2aBuilder(A2A_SERVER_URL, A2ACreativeWriter.class)
.outputKey("story")
.build();

定义工作流或将其作为主管智能体的子智能体时,该智能体可像本地智能体一样使用,并可与本地智能体混合使用。

远程 A2A 智能体必须返回 Task 类型。

与 A2A 服务器进行多轮对话

A2A 协议通过消息信封中的 contextIdtaskId 字段支持多轮对话。contextId 将相关任务分组为一次对话,而 taskId 引用该对话中的特定任务。省略时,A2A 服务器会生成新值;提供时,服务器会继续现有对话。

要在传出消息信封中传递这些字段,请使用 @A2AContextId@A2ATaskId 注解方法参数。这些参数不会作为消息内容发送——而是设置在消息信封中。

public interface ChatAgent {

@A2AClientAgent(a2aServerUrl = "http://localhost:8080", outputKey = "response")
String chat(@V("question") String question,
@A2AContextId @V("contextId") String contextId,
@A2ATaskId @V("taskId") String taskId);
}

当向 contextIdtaskId 传入 null 时,该字段会从信封中省略,服务器会创建新值。

@A2AContextId@A2ATaskId 参数同时具有可识别的名称(可能通过 @V 注解配置)时,服务器在响应中分配的值会自动以该名称写回 AgenticScope。这支持多轮流程:第一次调用捕获 ID,后续调用复用它们。

如果方法返回 ResultWithAgenticScope,可直接访问这些 ID:

public interface ChatAgent {

@A2AClientAgent(a2aServerUrl = "http://localhost:8080", outputKey = "response")
ResultWithAgenticScope<String> chat(
@V("question") String question,
@A2AContextId @V("contextId") String contextId,
@A2ATaskId @V("taskId") String taskId);
}

// First turn — server generates contextId and taskId
ResultWithAgenticScope<String> first = chatAgent.chat("hello", null, null);
String contextId = (String) first.agenticScope().readState("contextId");
String taskId = (String) first.agenticScope().readState("taskId");

// Second turn — reuse the server-generated IDs to continue the conversation
ResultWithAgenticScope<String> second = chatAgent.chat("follow-up", contextId, taskId);

通过这种方式,当 A2A 智能体用于智能体系统时,contextIdtaskId 会通过共享的 AgenticScope 自动传播。这意味着对同一服务器连续进行两次 A2A 调用会自然形成一次多轮对话:

public interface EchoSubAgent {

@A2AClientAgent(a2aServerUrl = "http://localhost:8080", outputKey = "response")
String echo(@V("question") String question,
@A2AContextId @V("contextId") String contextId,
@A2ATaskId @V("taskId") String taskId);
}

public interface MultiTurnWorkflow extends AgenticScopeAccess {

@Agent
ResultWithAgenticScope<String> converse(@V("question") String question);
}

EchoSubAgent firstTurn = AgenticServices
.a2aBuilder("http://localhost:8080", EchoSubAgent.class)
.outputKey("firstResponse").build();
EchoSubAgent secondTurn = AgenticServices
.a2aBuilder("http://localhost:8080", EchoSubAgent.class)
.outputKey("secondResponse").build();

MultiTurnWorkflow workflow = AgenticServices.sequenceBuilder(MultiTurnWorkflow.class)
.subAgents(firstTurn, secondTurn)
.outputKey("secondResponse").build();

ResultWithAgenticScope<String> result = workflow.converse("hello");

在该序列中,第一个智能体发送的消息不带 contextId/taskId(它们在作用域中为 null)。服务器会创建新任务和上下文。响应中的 ID 会写入作用域。第二个智能体运行时,会从现已填充的作用域读取 contextIdtaskId,将它们放入消息信封中发送,从而继续同一对话。

自定义 A2A 客户端

默认情况下,A2A 智能体使用默认配置的 JSONRPC 传输。clientCustomizer 方法公开底层 a2a-java SDK 的 ClientBuilder,允许你配置其他传输方式、设置自定义 HTTP 客户端、添加拦截器,或更改任何其他客户端设置。

以编程方式构建 A2A 智能体时,向 clientCustomizer 传入 Consumer<ClientBuilder>

UntypedAgent creativeWriter = AgenticServices
.a2aBuilder(A2A_SERVER_URL)
.clientCustomizer((ClientBuilder cb) ->
cb.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()))
.inputKeys("topic")
.outputKey("story")
.build();

提供自定义器时,它会完全替换默认传输设置,从而完全控制 Client 的构造方式。

对于声明式智能体,使用 @A2AClientCustomizer 注解静态方法。该方法必须接受单个 ClientBuilder 参数,并返回 void

public interface DeclarativeA2AWithCustomizer {

@A2AClientAgent(a2aServerUrl = "http://localhost:8080", outputKey = "story")
String generateStory(@V("topic") String topic);

@A2AClientCustomizer
static void customizer(ClientBuilder cb) {
cb.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder());
}
}

动态配置 A2A 服务器 URL

默认情况下,@A2AClientAgent 注解要求通过 a2aServerUrl 属性以编译时字符串字面量形式提供 A2A 服务器 URL。对于 URL 会变化的环境(例如开发、预发布、生产),可由使用 @A2AServerUrlSupplier 注解的静态方法在构建时动态提供 URL:

public interface DeclarativeA2AWithUrlSupplier {

@A2AClientAgent(outputKey = "story")
String generateStory(@V("topic") String topic);

@A2AServerUrlSupplier
static String serverUrl() {
return System.getenv("A2A_SERVER_URL");
}
}

供应方法必须是 static,不接受参数,并返回 String。它会在构造智能体时调用一次——URL 不会在调用之间发生变化。必须且只能提供注解中的 a2aServerUrl@A2AServerUrlSupplier 方法之一;同时指定两者(或两者均不指定)都会导致错误。

该模式与 @McpClientSupplier@McpClientAgent 声明式智能体提供 MCP 客户端的方式一致。

基于 MCP 的工具智能体

附加的 langchain4j-agentic-mcp 模块可将单个 MCP 工具封装为智能体系统中的非 AI 智能体。与使用 LLM 的常规智能体不同,MCP 工具智能体仅直接执行 MCP 工具并返回其结果。因此,无需让 LLM 参与工具执行本身,即可将 MCP 工具与其他智能体组合到更大型的智能体系统中。

要创建 MCP 工具智能体,使用 McpAgent.builder() 并提供 McpClient 实例。构建器会查询 MCP 服务器以获取工具规范(名称、描述、输入模式),并创建一个将调用转发给该工具的智能体。

例如,如果某 MCP 服务器公开 generate_story 工具,则可将其封装为无类型智能体:

McpClient mcpClient = new DefaultMcpClient.Builder()
.transport(myMcpTransport)
.build();

UntypedAgent storyGenerator = McpAgent.builder(mcpClient)
.toolName("generate_story")
.inputKeys("topic")
.outputKey("story")
.build();

String story = (String) storyGenerator.invoke(Map.of("topic", "dragons and wizards"));

当 MCP 服务器公开多个工具时,toolName 指定要绑定的工具。如果服务器仅公开一个工具,则可省略 toolName,系统会自动选择唯一可用的工具。inputKeys 指定工具输入参数的名称;对于无类型智能体,若未显式提供,它们也会从工具的 JSON 模式自动派生。

与其他智能体一样,MCP 工具智能体也可使用类型化接口创建:

public interface StoryGenerator {

@Agent
String generateStory(@V("topic") String topic);
}

StoryGenerator storyGenerator = McpAgent.builder(mcpClient, StoryGenerator.class)
.toolName("generate_story")
.outputKey("story")
.build();

String story = storyGenerator.generateStory("dragons and wizards");

在这种情况下,输入参数名称从方法参数(或其 @V 注解)派生,返回类型决定如何解析工具返回的文本结果。

最后,还可以使用 @McpClientAgent 注解以声明式方式定义 MCP 工具智能体。@McpClientSupplier 注解用于标记一个提供 McpClient 实例的静态方法。

public interface DeclarativeMcpStoryGenerator {

@McpClientAgent(toolName = "generate_story", outputKey = "story",
description = "Generates a story based on the given topic")
String generateStory(@V("topic") String topic);

@McpClientSupplier
static McpClient mcpClient() {
McpTransport transport = new StreamableHttpMcpTransport.Builder()
.url("http://localhost:8081/mcp")
.build();
return new DefaultMcpClient.Builder()
.transport(transport)
.build();
}
}