Skip to content
Embabel 文档版本 0.3.0-SNAPSHOT 源快照 f5e7c3dd 更新于 2026-09-29

17 · 构建聊天机器人(Chatbots)

聊天机器人是生成式 AI 的重要应用之一 —— 尽管远非唯一用途,在企业里尤其如此。

与许多其它框架不同,Embabel 并不维护一条对话线程来做它的核心工作。 这是件好事:它意味着大多数任务不需要做上下文压缩。

如果你想构建聊天机器人,就应该显式使用 Conversation 接口,并暴露一个 Chatbot bean; 它通常由一组处理 UserMessage 事件的动作方法来支撑。

核心概念(Core Concepts)

长生命周期的 AgentProcess

一个 Embabel 聊天机器人由长生命周期的 AgentProcess 支撑,它在用户消息之间暂停。这一设计有几项重要含义:

  • 同一个 AgentProcess 除了用户输入,也能响应其它事件;
  • 黑板在整个会话期间保持状态;
  • 动作可以由用户消息、系统事件、或加入黑板的其它对象触发;
  • 它是一个工作上下文(working context),而不只是一个聊天会话。

当用户发来一条消息,它会作为 UserMessage 加入黑板。 随后 AgentProcess 运行、选择一个恰当的动作来处理它,然后再次暂停,等待下一个事件。

聊天机器人适合用 Utility AI

对聊天机器人来说,Utility AI 往往是最佳方案。 你不定义固定流程,而是定义多个带成本的动作,由规划器为每条消息选出价值最高的动作来响应。这带来:

  • 多种响应策略(例如 RAG 检索、直接回答、请求澄清);
  • 基于上下文的动态行为;
  • 易于扩展 —— 加一个新的动作方法即可。

聊天机器人中的目标(Goals)

通常,聊天机器人 Agent 不需要目标。进程只是等待用户消息并无限期地响应它们。

不过,如果你希望确保对话能够终止、AgentProcess 完成而不是永远等待,也可以定义目标。这适用于:

  • 交易型对话(例如完成一次预订);
  • 有明确终点的向导式流程;
  • 收集到特定信息后就应结束的对话。

关键接口(Key Interfaces)

Chatbot

Chatbot 接口管理多个聊天会话:

java
public interface Chatbot {
    ChatSession createSession(
            User user,
            OutputChannel outputChannel,
            String contextId,
            String conversationId
    );

    ChatSession findSession(String conversationId);
}

Context ID 与会话状态

contextId 参数让你可以用某个命名上下文中的对象预填充会话黑板。适用于:

  • 用户有多个上下文 —— 一个用户可能有多套项目、账户或工作区,每个上下文可以维护自己跨会话持久的状态;
  • 恢复先前的状态 —— 用户回来时,恢复其上次的会话状态(例如用户偏好、进行中的工作、上一次会话的对话历史);
  • 预加载领域对象 —— 把那些"应当始终存在"的对象放进黑板,例如当前用户档案、有效订阅、相关配置。
java
// 用指定 context 创建会话
ChatSession session = chatbot.createSession(
        user,
        outputChannel,
        "project-alpha", // Context ID —— 载入该项目的已保存状态
        null
);

// 或者创建不带 context 的匿名会话
ChatSession anonymousSession = chatbot.createSession(
        null,
        outputChannel,
        null,
        null
);

该上下文机制与 AgentPlatform 的上下文存储协作:

  • 当带 contextId 调用 createSession 时,平台会查找该上下文已保存的对象;
  • 这些对象被加入新会话的黑板;
  • 会话运行期间,黑板上的变更可以持久化回该上下文;
  • 下次用同一 contextId 创建会话时,恢复更新后的状态。

这使跨会话的有状态对话成为可能,而不需要聊天机器人自己手动追踪与恢复状态。

ChatSession

每个会话代表一段进行中的对话:

java
public interface ChatSession {
    OutputChannel getOutputChannel();

    User getUser();

    Conversation getConversation();

    String getProcessId();

    void onUserMessage(UserMessage userMessage);

    boolean isFinished();
}

Conversation

Conversation 接口持有消息历史并追踪资产(assets):

java
public interface Conversation extends StableIdentified, AssetView {
    List<Message> getMessages();

    AssetTracker getAssetTracker();

    List<Asset> getAssets(); // 所有资产的合并视图

    Message addMessage(Message message);

    UserMessage lastMessageIfBeFromUser();
}

消息类型包括:

  • UserMessage —— 来自用户的消息(支持多模态内容);
  • AssistantMessage —— 聊天机器人的响应(可包含资产);
  • SystemMessage —— 系统级指令。

资产追踪(Asset Tracking)

聊天机器人可以在两个层级追踪资产(asset) —— 也就是生成出来的文档、检索结果、用户创建的内容等结构化输出。

会话级资产

Conversation 持有一个 AssetTracker,用于在整个会话期间显式追踪资产:

java
// 把资产加入会话追踪器
conversation.getAssetTracker().addAsset(myAsset);

// 取出所有被追踪的资产
List<Asset> trackedAssets = conversation.getAssetTracker().getAssets();

以下情况用会话级追踪:

  • 资产由工具或外部进程创建;
  • 资产需要跨多条消息持续存在;
  • 你想对被追踪的内容做显式控制。

消息级资产

AssistantMessage 实现了 AssetView,可以直接携带资产:

java
AssistantMessage message = new AssistantMessage(
        "Here's the report you requested",
        null, // name
        null, // awaitable
        List.of(reportAsset, summaryAsset) // assets
);
conversation.addMessage(message);

以下情况用消息级资产:

  • 资产直接绑定到某条具体响应;
  • 你想让资产在 UI 上与消息并排出现;
  • 该资产代表那次特定交互的产出。

合并资产视图

Conversation.assets 属性提供所有资产的合并视图:

java
// 同时取到「追踪器」与「所有消息」里的资产
List<Asset> allAssets = conversation.getAssets();

合并遵守以下规则:

  • 追踪器的资产排在前(显式追踪优先);
  • 消息里的资产按时间顺序排在后面;
  • 按 id 去重(以追踪器版本为准)。

这让资产管理很灵活:

java
@Action(canRerun = true, trigger = UserMessage.class)
void respond(Conversation conversation, ActionContext context) {
    // 从响应创建一个资产
    Asset resultAsset = createResultAsset(result);

    // 方式 1:加入消息(随这条响应一起出现)
    var message = new AssistantMessage(
            "Here's your analysis",
            null, null,
            List.of(resultAsset)
    );
    conversation.addMessage(message);

    // 方式 2:加入追踪器(显式追踪)
    conversation.getAssetTracker().addAsset(resultAsset);

    // 两种方式都能通过 conversation.getAssets() 看到
}

把资产当作工具使用

资产可以通过它们的 LlmReference 暴露给 LLM 作为工具:

java
// 从最近的资产取出 reference
List<LlmReference> refs = conversation.mostRecent(5).references();

// 在提示词里使用
var response = context.ai()
        .withReferences(refs) // 资产成为可用工具
        .respond(conversation.getMessages());

这支撑了如下场景:

  • 编辑此前生成的内容;
  • 组合多个资产;
  • 查询对话早期出现的结构化数据。

构建一个聊天机器人

第 1 步:创建动作方法

在一个 @EmbabelComponent 中定义动作方法,用 trigger 参数响应用户消息:

java
@EmbabelComponent
public class ChatActions {

    private final ToolishRag toolishRag;
    private final RagbotProperties properties;

    public ChatActions(
            SearchOperations searchOperations,
            RagbotProperties properties) {
        this.toolishRag = new ToolishRag(
                "sources",
                "Sources for answering user questions",
                searchOperations
        );
        this.properties = properties;
    }

    @Action(canRerun = true, trigger = UserMessage.class) // ① ②
    void respond(
            Conversation conversation,  // ③
            ActionContext context) {
        var assistantMessage = context.ai()
                .withLlm(properties.chatLlm())
                .withReference(toolishRag)
                .rendering("ragbot")
                .respondWithSystemPrompt(conversation, Map.of(
                        "properties", properties
                ));
        context.sendMessage(conversation.addMessage(assistantMessage)); // ④
    }
}

注记:

  • ① trigger = UserMessage.class —— 当 UserMessage 是最后一个加入黑板的对象时,该动作被调用;
  • ② canRerun = true —— 该动作可以执行多次(每条用户消息一次);
  • ③ Conversation 参数从黑板自动注入;
  • ④ context.sendMessage() 把响应发送到 output channel。

第 2 步:配置 Chatbot bean

用 AgentProcessChatbot.utilityFromPlatform() 创建一个基于 utility 的聊天机器人,它会发现所有可用动作:

java
@Configuration
class ChatConfiguration {

    @Bean
    Chatbot chatbot(AgentPlatform agentPlatform) {
        return AgentProcessChatbot.utilityFromPlatform(agentPlatform); // ① ②
    }
}

① 创建一个用 Utility AI 规划来选择最佳动作的聊天机器人 ② 发现平台上所有 @EmbabelComponent 类里的 @Action 方法

为便于调试,可以传入自定义 Verbosity 配置:

java
@Bean
Chatbot chatbot(AgentPlatform agentPlatform) {
    return AgentProcessChatbot.utilityFromPlatform(
            agentPlatform,
            new InMemoryConversationFactory(), // ①
            new Verbosity().showPrompts()      // ②
    );
}

① conversation 工厂(指定 verbosity 时必填) ② 用于调试提示词的 verbosity 配置

⚠️ 请确保在 AgentProcessChatbot 上创建新会话之前,AgentPlatform 已加载完所有动作, 否则响应聊天所需的动作可能在会话中不可用。

对话存储(Conversation Storage)

默认情况下,聊天机器人使用内存中的对话,会话结束后即丢失。 生产应用通常需要把对话持久化到后端存储。

消息事件(MessageEvent)与内存对话类型位于 embabel-agent-api,因此不依赖 embabel-chat-store 也能使用。 通过给 InMemoryConversationFactory 传入 Spring 的 ApplicationEventPublisher, 内存对话也能发布 MessageEvent(状态为 ADDED)。

存储类型

Embabel 通过 ConversationStoreType 支持两种对话存储类型:

类型说明
IN_MEMORY对话只存在内存中。快而简单,适合测试与临时会话。
STORED对话持久化到后端存储(例如 Neo4j)。需要 embabel-chat-store 依赖。

配置持久化存储

要使用持久化对话,请注入 ConversationFactoryProvider,并在创建聊天机器人时传入合适的工厂:

java
@Configuration
class ChatConfiguration {

    @Bean
    Chatbot chatbot(
            AgentPlatform agentPlatform,
            ConversationFactoryProvider conversationFactoryProvider) { // ①

        ConversationFactory factory = conversationFactoryProvider
                .getFactory(ConversationStoreType.STORED); // ②

        return new AgentProcessChatbot(
                agentPlatform,
                user -> createAgent(agentPlatform),
                factory, // ③
                // ... 其它配置
        );
    }
}

① 通过 Spring DI 注入 ConversationFactoryProvider ② 取目标存储类型对应的工厂 ③ 把工厂传给聊天机器人 —— 存储在创建时配置一次

存储类型在创建聊天机器人时配置一次,而不是按调用配置。 这保证了所有会话行为一致。

添加 embabel-chat-store

要启用持久化存储,请添加 embabel-chat-store 依赖:

xml
<dependency>
    <groupId>com.embabel.chat</groupId>
    <artifactId>embabel-chat-store</artifactId>
</dependency>

它提供:

  • StoredConversationFactory —— 创建会持久化到 Neo4j 的对话;
  • StoredConversation —— 带异步持久化的对话实现;
  • 持久化生命周期事件(MessageEvent,状态为 PERSISTED / PERSISTENCE_FAILED),供 UI 更新;
  • 会话的标题生成。

恢复对话

要恢复一个对话,在创建会话时传入 conversationId:

java
// 恢复已有对话,或创建新的
ChatSession session = chatbot.createSession(
        user,
        outputChannel,
        null,            // contextId
        conversationId   // ①
);

// 若对话已存在,消息已经加载好了
List<Message> history = session.getConversation().getMessages();

① 若该对话在存储中存在,会被自动载入;若不存在,则用该 ID 创建一个新对话。

这让应用可以:

  • 跨服务重启恢复对话;
  • 向回访用户展示对话历史;
  • 从上次中断处继续多轮交互。

需要更底层访问时,你也可以直接用 ConversationFactory.load(conversationId), 在创建会话之前先判断对话是否存在。

第 3 步:使用聊天机器人

通过会话接口与聊天机器人交互:

java
// 新会话(全新状态,自动生成 conversation ID)
ChatSession session = chatbot.createSession(user, outputChannel, null, null); // ①

// 带上下文的会话(恢复黑板状态)
ChatSession withContext = chatbot.createSession(user, outputChannel, "user-workspace-123", null); // ②

// 按 ID 恢复已有对话
ChatSession restored = chatbot.createSession(user, outputChannel, null, savedConversationId); // ③

// 同时使用上下文与对话恢复
ChatSession full = chatbot.createSession(user, outputChannel, "user-workspace-123", savedConversationId); // ④

session.onUserMessage(new UserMessage("What does this document say about taxes?")); // ⑤
// 响应会自动发送到 outputChannel

① 创建全新黑板、自动生成 conversation ID 的新会话 ② 从 "user-workspace-123" 上下文载入先前的黑板状态 ③ 恢复一个既有对话及其消息历史 ④ 两者兼具:既载入上下文状态,又恢复对话历史 ⑤ 发送一条用户消息 —— 触发 Agent 选择并运行一个动作

消息触发是怎么工作的(How Message Triggering Works)

当你在动作上指定 trigger = UserMessage.class 时:

  • 聊天机器人把 UserMessage 同时加入 Conversation 与 AgentProcess 黑板;
  • 规划器评估所有 trigger 条件已满足的动作;
  • 对 utility 规划,选**价值最高(成本最低)**的那个动作;
  • 该动作方法通过参数注入拿到 Conversation(其中已包含新消息)。

这种基于 trigger 的方式意味着:

  • 你可以有多个以不同成本响应用户消息的动作;
  • 规划器会挑出最恰当的响应策略;
  • 动作也可以由其它事件类型触发(不限于 UserMessage)。

动态成本方法(Dynamic Cost Methods)

要做更精细的动作选择,使用 @Cost 方法:

java
@Cost // ①
double dynamic(Blackboard bb) { // ②
    return bb.getObjects().size() > 5 ? 100 : 10; // ③
}

@Action(canRerun = true,
        trigger = UserMessage.class,
        costMethod = "dynamic") // ④
void respond(Conversation conversation, ActionContext context) {
    // ...
}

① @Cost 标记这是一个成本计算方法 ② 接收 Blackboard 以检视当前状态 ③ 返回成本值 —— 成本越低优先级越高 ④ costMethod 把动作与成本计算方法关联起来

提示词模板(Prompt Templates)

聊天机器人通常使用 Jinja 提示词模板,而不是行内字符串提示词。 这并非硬性要求 —— 简单聊天机器人完全可以用代码里拼的普通字符串提示词:

java
var assistantMessage = context.ai()
        .withLlm(properties.chatLlm())
        .withSystemPrompt("You are a helpful assistant. Answer questions concisely.") // ①
        .respond(conversation.getMessages());

① 简单的行内提示词 —— 基础聊天机器人够用

不过,生产级聊天机器人往往需要更长、更复杂的提示词,用于:

  • 人格与语气(persona);
  • 护栏与安全指令;
  • 领域特定目标;
  • 依据配置的动态行为。

这些情况下,Jinja 模板是更好的选择:

java
var assistantMessage = context.ai()
        .withLlm(properties.chatLlm())
        .withReference(toolishRag)
        .rendering("ragbot") // ①
        .respondWithSystemPrompt(conversation, Map.of( // ②
                "properties", properties,
                "persona", properties.persona(),
                "objective", properties.objective()
        ));

① 从 resources 加载 prompts/ragbot.jinja ② 模板绑定 —— 在 Jinja 中可作为 properties.persona() 等访问

模板带来:

  • 提示词工程与代码分离;
  • 通过配置动态选择 persona 与 objective;
  • 可复用的提示词元素(护栏、个性化);
  • 不必改代码即可迭代提示词。

用 respond 实现"永不哑火"的响应

在聊天机器人里,绝不能让用户收不到回复,这一点至关重要。 Rendering 上的 respond 方法为 respondWithSystemPrompt 包上了错误处理 —— 即便出现 LLM 或基础设施故障,也仍然返回一条 AssistantMessage 给用户,而不是把异常抛出去:

java
var assistantMessage = context.ai()
        .rendering("ragbot")
        .respond(conversation, model, error -> {
            logger.error("Failed to generate response", error);
            return new AssistantMessage("Sorry, something went wrong. Please try again.");
        });

模板结构示例

下面是 rag-demo 项目中一个典型的聊天机器人模板结构:

prompts/
├── ragbot.jinja             # 主入口
├── elements/
│   ├── guardrails.jinja     # 安全限制
│   └── personalization.jinja # 动态 persona/objective 加载器
├── personas/
│   ├── clause.jinja         # 法律专家 persona
│   └── ...
└── objectives/
    └── legal.jinja          # 法律文档分析目标

主模板(ragbot.jinja)由可复用元素组合而成:

jinja
{% include "elements/guardrails.jinja" %}      {# ① #}

{% include "elements/personalization.jinja" %}  {# ② #}

① 先引入安全护栏 ② 再引入 persona 与 objective(它们是动态选择的)

护栏定义安全边界(elements/guardrails.jinja):

jinja
{# Safety and content guardrails for the ragbot. #}

DO NOT DISCUSS POLITICS OR CONTROVERSIAL TOPICS.

个性化会动态加载 persona 与 objective(elements/personalization.jinja):

jinja
{% set persona_template = "personas/" ~ properties.persona() ~ ".jinja" %}   {# ① #}
{% include persona_template %}

{% set objective_template = "objectives/" ~ properties.objective() ~ ".jinja" %} {# ② #}
{% include objective_template %}

① 由 properties.persona() 拼出模板路径(例如 "clause" → "personas/clause.jinja") ② 由 properties.objective() 拼出模板路径(例如 "legal" → "objectives/legal.jinja")

一个 persona 模板(personas/clause.jinja):

jinja
Your name is Clause.
You are a brilliant legal chatbot who excels at interpreting
legislation and legal documents.

一个 objective 模板(objectives/legal.jinja):

jinja
You are an authoritative interpreter of legislation and legal documents.
You are renowned for thoroughness and for never missing anything.

You answer questions definitively, in a clear and concise manner.
You cite relevant sections to back up your answers.
If you don't know, say you don't know.
NEVER FABRICATE ANSWERS.

You ground your answers in literal citations from the provided sources.
Always use the available tools. // ①

① 指示 LLM 使用通过 withReference() 提供的 RAG 工具

这种模块化做法让你可以:

  • 通过 application.yml 切换 persona,不用改代码;
  • 在多个聊天机器人配置间共享护栏;
  • 独立测试不同的 objective。

进阶:用 @State 管理状态

对于需要跨消息追踪状态的复杂聊天机器人,请使用 @State 类。状态类由 Agent 框架自动管理:

  • 状态对象持久化在黑板中;
  • 动作可以依赖"某个特定状态存在";
  • 状态迁移驱动对话流程。

关于定义状态类、状态相关动作、嵌套状态机等细节,请交叉参考 @State 注解与使用状态。

完整示例(Complete Example)

完整的聊天机器人实现见 rag-demo 项目,包括:

  • ChatActions.java —— 响应用户消息的动作方法;
  • ChatConfiguration.java —— Chatbot bean 配置;
  • RagbotShell.java —— 用于交互式测试的 Spring Shell 集成;
  • Jinja 模板:persona 驱动的提示词;
  • RAG 集成:让回答有文档依据。

运行示例:

bash
./scripts/shell.sh

# 在 shell 中:
ingest ./data/document.md
chat
> What does the document say about...

BytesRouter 是社区翻译站点,所有译文仅供参考,一切以官方英文原文为准。