使用 MCP 与 A2A 设计多智能体 AI 系统——使用 Slack 和 Chainlit 创建聊天界面

0 阅读33分钟

在上一章中,我们探讨了 AI-6 的内部工具架构——工具如何被定义、注册与执行。强大的后端固然重要,但一个智能体 AI 系统的真正潜力,需要通过“让人类能够与自主智能体高效协作”的接口才能被释放出来。

智能体系统中的用户界面(UI)承担着独特角色。不同于传统应用里 UI 主要负责收集输入、展示输出,智能体接口必须在“自治”与“控制”之间取得平衡:它们需要提供智能体正在做什么的实时可见性(visibility),在必要时允许中断(interruptions),在失败时呈现调试信息(debugging information),并通过透明性建立信任(trust)。挑战在于:要设计一种界面,既不会用过度监管扼杀智能体,也不会让用户对其行为一无所知。

本章将介绍 AI-6 如何实现两种互补的接口:用于团队协作工作流的 Slack 机器人,以及面向个人交互的、基于 Chainlit 的 Web UI。

本章涵盖以下主题:

  • 为什么 UI 对智能体 AI 工作流很重要:可见性、可中断性、调试与信任
  • 构建支持多频道与实时协作的 Slack Bot 接口
  • 使用 Chainlit 开发富交互、可定制的单用户 Web UI
  • 将前端接口与 AI-6 引擎及工具生态集成
  • 设计交互式智能体界面的最佳实践

技术要求

要跟着实践,请参考 README 中的说明:
github.com/PacktPublis…

为什么 UI 在智能体 AI 工作流中至关重要

智能体 AI 系统的“成名之处”在于:它们具备自治能力,能代表用户执行复杂任务。然而,这种自动化往往需要与人类的监督、控制,甚至只是对智能体正在做什么的可见性相平衡。UI 在智能体 AI 系统的成功中扮演关键角色:它是连接用户与智能体行为的桥梁。本节将从几个关键方面说明 UI 为什么在智能体工作流中不可或缺。

智能体反馈回路与可见性

从智能体 AI 系统的人类用户视角来看:在“让智能体完全自主运行(可能会犯错、跑偏)”与“对智能体进行过度微观管理(盯着每一步、随时纠正)”之间,存在一种微妙平衡。注意,这两种极端在不同场景下其实都可能有用。

以自动化网站上的复杂工作流为例:包括登录、在不同页面间导航、收集信息、基于信息采取行动,以及响应变化通知。用户可能熟悉该任务,但需要“教会”智能体如何做。如果用户追求完全自动化,那么提示词必须非常详尽,并且极其精确地表达意图。

这很难做到。因此,用户可能先从较通用的提示词开始,让智能体先做出部分成功的执行,再根据执行结果不断细化提示词。随着时间推移,用户能够在提示词中更细腻地描述完整工作流。到那时,除非发生极少见且出乎意料的情况,智能体就可以在无需用户干预的前提下自主完成任务。

另一个重要方面是:当智能体工作时的可见性与进度更新(progress updates)。这对长耗时任务尤其重要。用户可能想知道智能体在做什么、还需要多久、是否正在推进。UI 应该对智能体的动作、状态以及出现的问题提供实时反馈。举个任务例子:分析 Netflix 最近三个月新上线的所有电影并找一部好看的。智能体可能会花很长时间,但用户可能希望看到阶段性结果,甚至在完整分析完成前就决定先去看一部电影。

这就引出了智能体 AI 系统 UI 的下一个关键点:能够中断智能体,并选择终止任务或把它引导到不同方向。

可中断性与 Human-in-the-Loop 设计

让人类能够中断智能体并接管控制,有很多设计方式。首先,系统可以限制一次 agentic loop 的运行时间:通过配置时间上限、操作次数上限,甚至按成本(例如 token 使用量)限制。智能体达到上限后会停止,向用户汇报,并等待进一步指令。

这种方式的好处是:用户始终处于控制之中,可以决定继续、修改或终止任务。缺点是:在正常情况下(进展顺利、智能体效率很高),用户仍然得坐在那里不断点“继续”。这对长任务而言可能既枯燥又令人沮丧。尽管如此,如果用户手工完成每一步远比智能体慢得多,这仍可能是高效方法。当然,如果用户不够专注,AI 系统就会一直暂停,等用户继续。

另一种方式是:允许智能体自主运行,同时持续提供进度报告与状态更新。用户可以在任何时刻中断智能体,更新任务或直接取消。

还有一些办法可以进一步改善体验。我们可以让 LLM 自己决定何时暂停并向用户汇报。例如,提示词可以包含“每 10 步或超过 50,000 tokens 就暂停并向用户汇报”之类的指令。这样系统可以自主运行,同时提供周期性检查点(checkpoints)。如果想更进一步,还可以加入更智能的暂停条件,例如“如果你觉得无法推进,就向用户汇报”。

当然,这也适用于状态更新与通知。智能体系统可以只在遇到异常情况或有重要更新时通知用户。LLM 会基于提示词与对话历史判断哪些情况需要通知用户。这样用户不会被持续的通知轰炸,但仍能保持对智能体行为的可见性,并在关键事件上被提醒。

诊断与调试智能体行为

UI 另一个至关重要的用例是诊断与调试智能体行为。当智能体表现不佳时,可能有很多原因:

  • 智能体信息不足
  • 提示词过于模糊
  • 工具不支持所需的全部动作
  • LLM 本身不适合该任务(模型不对)

还有性能问题:智能体确实完成了任务,但耗时太长或消耗了过多 tokens。别忘了,失败也可能由多种原因叠加导致。

因为潜在原因太多,调试用 UI 必须尽可能暴露信息:包括初始提示词、每个智能体使用的工具、完整对话历史(包含每一次工具调用及其参数)、LLM 的响应,以及在很多情况下,工具访问到的外部系统的额外日志与指标。

例如,如果 AI 系统在诊断 Kubernetes 集群问题时行为异常,那么调试应该包含目标 Kubernetes 集群的遥测与日志。

这种级别的审视对诊断与调试必不可少,但对普通用户而言通常过于嘈杂。这意味着你需要为不同用户角色设计不同 UI:AI 系统开发者与运维人员需要非常细粒度的视图;终端用户可能只需要一个高层视图,用合适的抽象层级展示智能体动作与状态更新。像工具调用细节、token 计数等信息对终端用户可能太多,但对开发者与运维却是关键。

在自治与用户控制之间取得平衡

如本章开头所述,在让智能体自主运行与用过多用户控制“压制”它之间,存在微妙平衡。UI 在管理这种平衡上起关键作用。理想情况下,系统应允许用户管理授予智能体的自治程度。

例如,Claude Code(见 docs.anthropic.com/en/docs/cla… )是一个在终端运行的智能体编码助手,它通过合适的用户控制在多个层级上解决智能体自治问题。首先,它在执行任务时持续输出进度状态更新。当有人问 Claude “AI-6 的测试是否都通过”时,它先去找出所有测试:它实时列出在代码库里找到的测试,并提供大量实时信息,例如使用了多少工具、总耗时、token 数等。它也可以通过按 Esc 键被中断:

image.png

图 6.1:检查 AI-6 测试

当 Claude Code 找到所有测试后,它会自动开始运行,而不会等待批准。它发现有些测试失败了,甚至定位到了根因(缺少 sh 模块):

image.png

图 6.2:运行 AI-6 测试

它之所以直接运行测试,是因为 Claude Code 有一个配置文件,里面声明了允许执行某些动作的权限。在之前的会话里,Claude 被允许运行测试,因此不需要再次请求权限。如果用户想改变这一点,可以编辑 .claude/settings.local.json 并移除该权限。片段如下:

{
  "permissions": {
    "allow": [
      "Bash(chainlit run:*)",
      "Bash(git add:*)",
      "...",
      "Bash(rg:*)",
      "Bash(cat:*)",
      "Bash(PYTHONPATH=./:$PYTHONPATH python3 -m unittest discover backend/tests -v)",
      "Bash(ls:*)",
      "Bash(mkdir:*)",
      "Bash(./ai6.sh:*)"
    ],
    "deny": []
  },
  "enableAllProjectMcpServers": false
}

Claude Code 想安装缺失的 sh 模块,但因为它没有自动安装包的权限,所以它会请求权限。实际上并不需要安装 sh 模块,因为它已经安装在 AI-6 的虚拟环境里。正确做法是激活虚拟环境并重新运行测试:

image.png

图 6.3:请求安装包权限

这段与 Claude Code 的快速交互说明:UI 如何在 AI 系统自治与用户控制之间提供平衡。但 UI 不只是控制,也关乎信任。

UI 作为建立信任的机制

智能体 AI 系统越自治,提供的价值就越大——当然前提是它们确实能正确工作并达成用户目标。因为这类系统往往要做复杂决策,UI 必须让其行为透明、可解释。可信 UI 会展示智能体在做什么、为什么这么做、下一步计划做什么。它要向用户暴露“恰当层级”的信息:可能包括工具调用、决策点与中间步骤。核心理念是:即便推理并不完美,用户也能跟得上其思路。当用户能把错误追溯到原因时,更可能去改进输入,而不是直接放弃系统。

信任还依赖清晰的边界与控制。用户需要知道智能体被允许执行哪些动作、何时会请求许可。像 Claude Code 使用的显式权限系统能让用户确信:智能体不会在缺乏监督的情况下做出高风险修改。同时,UI 也应当表达不确定性:当 LLM 不确定或需要帮助时要明确提示。这种“可见性 + 克制 + 谦逊”的组合,会促成一种协作关系,让用户感到自己始终掌控全局、信息充分,而不是被边缘化或被意外惊吓。

现在我们已经讨论了 UI 在智能体 AI 工作流中的重要性,接下来我们将探索如何构建这样的接口。

构建 Slack Bot 接口

在深入 AI-6 的 Slack Bot 接口实现细节之前,先提醒一句:像 LangGraph、AutoGen、CrewAI 以及我们自己的 AI-6 这类智能体框架,核心关注点通常是定义智能体与工具、管理记忆与会话,以及让任务自主执行。基于这些框架之上构建的 AI 系统,才需要对 UI 负责,而 UI 往往会针对具体用例或领域做定制。AI-6 之所以提供多个 UI,主要是出于教学目的。

Slack Bot 接口具备一些独特特性,使其成为某些智能体 AI 系统的理想选择:

  • 熟悉度(Familiarity) :很多用户本来就熟悉 Slack,上手成本低
  • 实时交互(Real-time interaction) :Slack 支持实时通信,这对交互式智能体工作流至关重要
  • 多用户支持(Multi-user support) :支持多用户与多频道,适合协作式工作流
  • 与其他工具集成(Integration with other tools) :Slack 可集成多种工具与服务,更容易构建复杂工作流
  • 通知与告警(Notifications and alerts) :可发送通知/告警,适合长耗时任务或重要更新
  • 安全与访问控制(Security and access control) :提供内置安全能力(身份认证、权限控制等),这对可能处理敏感数据的智能体系统很关键

理解了这些优势之后,我们来看看 AI-6 的 Slack Bot 在实践中到底怎么工作。

先体验一下 AI-6 Slack Bot

在深入原理之前,我们先把 AI-6 Slack Bot 跑起来看看效果。可以用方便的 ai6.sh 脚本启动,并传入 slack 参数:

❯ ./ai6.sh slack
virtualenv: /Users/gigi/git/ai-six/py/venv
Loaded environment variables from /Users/gigi/git/ai-six/py/frontend/slack/.env
Joined ai-6-test
Bolt app is running!

此时会创建一个名为 bot-playground 的 Slack workspace,并包含一个名为 ai-6-test 的频道(见图 6.4)。AI-6 Slack Bot 是一个 Slack app(api.slack.com/docs/apps),它会自动给任何频道加上 ai-6- 前缀,并开始监听消息。

image.png

图 6.4:#ai-6-test Slack 频道

AI-6 Slack Bot 连接到 AI-6 引擎,因此可以访问 AI-6 的所有工具与能力。我们看看它是否知道当前目录是什么:

image.png

图 6.5:询问当前目录

注意:除了回复消息外,还会在与该消息关联的 thread 里有一条回复。展开 thread,就能看到 AI-6 为生成最终回答而执行的工具调用(由 LLM 发起请求,AI-6 执行):

image.png

图 6.6:观察工具调用

这里有一个体验层面的设计选择:工具调用不会显示在主消息里,而是放在 thread 中。这是个不错的选择:主消息保持干净,只聚焦最终回答;而对感兴趣的用户,仍然能在 thread 里查看回答是如何生成的细节。我们再试一个包含多次工具调用的请求:

image.png

图 6.7:多次工具调用检查当前目录文件

很好,AI-6 Slack Bot 能处理多个工具调用并给出正确答案。但它具体怎么做到的?我们查看 thread 里的工具调用:LLM 让 AI-6 调用 ls 列出当前目录下所有文件——目前为止没问题。但接着它又让 AI-6 对每个文件调用 cat 读取全文并回传,以检查是否包含 import 这个词。

这虽然“逻辑正确”,但极其低效。对于大文件甚至不可行,因为文件内容可能塞不进上下文窗口。更有效的做法是用类似 grep 的工具一次性在所有文件中搜索 import

这就是一个很典型的例子:LLM 在请求工具调用时可能做出次优选择,而我们可以通过 UI 暴露“真实工具调用”的方式来调试这类问题(而不是只统计执行了多少次工具调用)。

image.png

图 6.8:更细节的工具调用可视化

理想情况下,这类糟糕的工具选择应该在 AI 系统开发阶段被发现,然后通过给 LLM 更好的说明或 system prompt 来修正。在这里,我们试试是否能在这个 session 里把它改得更好。有意思的是:当被问到“为什么不用 grep 而用 cat”时,AI-6 也承认 cat 很低效并建议使用 grep,但最终它却用 sed 去搜索文件里的 import。这也可以,但与它宣称要做的并不一致:

image.png

图 6.9:调试工具使用选择

现在我们已经对 AI-6 Slack Bot 的行为与用户体验有了直观感受,下面看它是如何实现、底层如何运作的。

认证与配置设置

AI-6 Slack Bot 使用 Slack Bolt(slack.dev/bolt-python…)框架构建,它简化了 Python 中对 Slack API 的集成。请记住:为了跟着本章学习,你不需要自己配置或部署 Slack app。我们会走读 Slack 接口的设计、结构与行为,让你无需动手配置也能理解它如何工作。

不过,为了提供背景,这里说明一下幕后需要做的配置:
会创建并配置一个 Slack app(api.slack.com/apps),包括:

如果你之后想自己做 Slack 集成,官方指南(api.slack.com/start/build…)是个很好的起点。

注意:Bolt app 可以通过 Slack CLI 启动,也可以直接通过 Python 启动。AI-6 使用 Python 方式,以便更灵活地控制行为,并更深度地集成到 AI 系统运行时。

AI-6 Slack Bot 是 AI-6 引擎的一个前端,而 AI-6 引擎初始化需要一个配置对象(你在第 4 章见过)。配置对象可从 JSON、YAML、TOML 等格式加载。Slack Bot 使用 config.toml 配置 AI-6 引擎,内容如下:

# AI-6 Slack Configuration File
default_model_id = "gpt-4o"
tools_dir = "${HOME}/git/ai-six/py/backend/tools"
mcp_tools_dir = "${HOME}/git/ai-six/py/backend/mcp_tools"
memory_dir = "${HOME}/git/ai-six/memory/slack"
checkpoint_interval = 3
#Provider configuration
[provider_config.openai]
api_key = "${OPENAI_API_KEY}"
default_model = "gpt-4o"

[provider_config.ollama]
model = "qwen2.5-coder:32b"

#Tool configuration
[tool_config.claude]
api_key = "${ANTHROPIC_API_KEY}"

可以看到,配置文件中嵌入的是环境变量名,而不是敏感信息本身。这是 AI-6 配置系统提供的一项能力:它支持把 ${VAR}$VAR 形式的环境变量自动插值加载。Config 类中使用如下代码实现:

    @staticmethod
    def _interpolate_env_vars(value: Any) -> Any:
        """Recursively interpolate environment variables in a configuration value.
     
        Supports ${VAR} and $VAR syntax for environment variables.
     
        Parameters
        ----------
        value : Any
            The value to interpolate
         
        Returns
        -------
        Any
            The interpolated value
        """
        if isinstance(value, str):
            # Handle ${VAR} syntax
            if "${" in value and "}" in value:
                import re
                pattern = r'${([a-zA-Z0-9_]+)}'
                matches = re.findall(pattern, value)
             
                for var_name in matches:
                    env_value = os.environ.get(var_name, '')
                    value = value.replace(f"${{{var_name}}}", env_value)
                 
            # Handle $VAR syntax
            elif value.startswith('$') and len(value) > 1:
                var_name = value[1:]
                value = os.environ.get(var_name, '')
             
            return value
        elif isinstance(value, dict):
            return {k: Config._interpolate_env_vars(v)
                for k, v in value.items()}
        elif isinstance(value, list):
            return [Config._interpolate_env_vars(item) for item in value]
        else:
            return value

你并不一定要在配置文件里嵌环境变量。你也可以直接把敏感信息写入 config 文件——但一定不要把这种文件提交到版本控制。你也可以完全不使用配置文件,而是通过代码程序化创建 Config 对象(比如从数据库或 S3 读取配置)。

但这里我们是通过 config 文件加载环境变量并传给 AI-6,因此在运行 Slack Bot 前要先设置环境变量。AI-6 Slack Bot 使用一个 .env 文件来填充 Slack 需要的多项环境变量,以及 AI-6 LLM provider 的密钥。下面是 AI-6 Slack Bot 使用的(打码后的).env 文件:

OPENAI_API_KEY=<redacted>
ANTHROPIC_API_KEY=<redacted>
AI6_APP_ID=<redacted>
AI6_CLIENT_ID=<redacted>
AI6_CLIENT_SECRET=<redacted>
AI6_SIGNING_SECRET=<redacted>
AI6_BOT_TOKEN=<redacted>
AI6_APP_TOKEN=<redacted>

是的,环境变量很多,但都必需。这类文件不能提交到版本控制,AI-6 的 .gitignore 已经忽略它们:

# Environment files
**/.env
**/.env.*

Slack Bot 初始化

AI-6 Slack Bot 代码在 frontend/slack 目录下。主入口是 app.pygithub.com/PacktPublis…),它也包含了大部分 bot 逻辑。下面是 imports:包括标准库(如 os、functools.partial)、pathology.path 包、若干 Slack/Bolt 包,以及 frontend/slack 目录下的 utils 模块(用于连接 AI-6 引擎):

import os
from functools import partial

import pathology.path
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from dotenv import load_dotenv

from slack_sdk.errors import SlackApiError

from . import utils

接下来是加载 .env 文件并导入环境变量。如果找不到 .env 文件,会打印 warning 并继续执行——因为环境变量也可能在启动脚本前就已经设置好了,所以即使没有 .env bot 仍可能运行:

script_dir = pathology.path.Path.script_dir()

# Try to load environment variables from .env file in the same directory as this script
env_file_path = os.path.join(script_dir, ".env")
if os.path.exists(env_file_path):
    load_dotenv(env_file_path)
    print(f"Loaded environment variables from {env_file_path}")
else:
    print(f"Warning: No .env file found at {env_file_path}")

此时环境变量已加载,可以通过 os.environ.get() 访问。代码初始化 Slack app token 与 bot token,并创建一个 Bolt app 实例:

app_token = os.environ.get("AI6_APP_TOKEN")
bot_token = os.environ.get("AI6_BOT_TOKEN")
app = App(token=bot_token)

然后初始化一些变量:保存最后消息与最新时间戳、记录专用 AI-6 频道的字典、定位配置文件路径、以及一个空的 engines 字典:

last_message = ""
latest_ts = None
ai6_channels = {}

config_path = str((script_dir / 'config.toml').resolve())
engines = {}

完成基础初始化后,我们来看启动 Slack bot 的 main() 函数。它主要做两件事:加入一个频道,并启动 SocketModeHandler 监听事件。它也处理异常,并在结束时离开所有加入过的频道:

def main():
    """Main entry point for the Slack app"""
    channel = join_channel(app.client)
    if channel is not None:
        print(f'Joined {channel["name"]}')

    try:
        # Run the Slack app
        SocketModeHandler(app, app_token).start()
    except KeyboardInterrupt:
        print("Ctrl+C detected.")
    except Exception as e:
        print(f"Unhandled exception: {e}")
    finally:
        leave_channels(app.client)


if __name__ == "__main__":
    main()

至此,我们理解了 AI-6 Slack Bot 如何初始化与启动。下一步是:理解它如何处理消息,以及如何与 AI-6 引擎交互来处理这些消息。

消息处理与事件处理

Bolt app 通过 decorator 来处理事件:把特定事件类型映射到 handler 函数。AI-6 Slack Bot 通过一个 handle_message() 函数处理任意频道中的所有消息,它带有 @app.event("message") 装饰器:

@app.event("message")
def handle_message(message, say,ack, client):

    """Handle all messages in channels."""

该函数声明对 last_messagelatest_ts 的全局引用,并从 message 对象里提取文本:

global last_message, latest_ts
ack()  # Acknowledge ASAP

text = message.get("text")

Slack bot 的设计是:处理两类消息——
1)它自己的专用频道(ai6_channels 字典里的频道)中的消息;
2)其他频道里“@提及 bot”的消息。

如果消息为空、来自另一个 bot、或不满足上述条件,就忽略:

    # Skip if the message is empty or from a bot
    if not text or message.get("bot_id"):
        return

    mention_bot = f"<@{bot_user_id}>" in text
    channel_id = message['channel']
    ai6_channel = channel_id in ai6_channels

    # Ignore messages that don't mention the bot and are not a special AI-6 channel
    if not mention_bot and not ai6_channel:
        return

到这里,我们已经拿到了要处理的消息。有了 channel_id,就可以为该 channel 创建或复用一个 AI-6 engine 实例。接着,准备一个工具调用处理器,用于处理 AI-6 engine 在消息处理过程中触发的工具回调(这些回调会写入 reply thread,如前所述):

    engine = get_or_create_engine(channel_id)
 
    # Create a tool call handler for this channel
    channel_tool_call_handler = partial(handle_tool_call, client, channel_id)

现在处理消息所需的一切都齐了。我们会让 AI-6 engine 以 streaming 模式工作:边生成边输出 chunk,不需要等完整回答。实现上会先发一条通用的 “...” 消息;然后把 latest_ts 设为 chat_postMessage() 返回的 timestamp;last_message 初始化为空字符串,用于累计拼接流式输出:

    # Process the message with the AI-6 engine
    try:
        # Post an initial empty message that we'll update
        result = client.chat_postMessage(
            channel=channel_id,
            text="..."
        )
 
        latest_ts = result["ts"]
        last_message = ""

接着定义一个内联的 handle_chunk() 函数:AI-6 engine 每产生一个 chunk 就回调它。这里用嵌套函数略显少见,但好处是把 callback 放在使用位置附近,代码可读性更好。每来一个 chunk,就追加到 last_message,并通过 chat_update() 更新 Slack 消息;如果更新失败则打印错误:

        # Define a callback function to handle streaming chunks
        def handle_chunk(chunk):
            global last_message
            last_message += chunk
     
            try:
                # Update the message with the new content
                client.chat_update(
                    channel=channel_id,
                    ts=latest_ts,
                    text=last_message
                )
            except SlackApiError as e:
                print(f"Error updating message: {e}")

最后部分:把消息流式送入 AI-6 engine,并传入两个事件处理器:一个处理 chunk,一个处理 tool call。若 streaming 过程中出错,会捕获异常并输出到终端:

        # Stream the message to the AI-6 engine
        response = engine.stream_message(
            text,
            engine.default_model_id,
            on_chunk_func=handle_chunk,
            on_tool_call_func=channel_tool_call_handler
        )
 
    except Exception as e:
        print(f"I encountered an error: {str(e)}")

现在我们理解了 bot 如何处理消息。接下来我们把难度提升一点:多频道与多用户。

频道管理与多用户支持

AI-6 Slack Bot 被设计为支持多个 Slack 频道与用户。它会自动加入第一个名字带 ai-6- 前缀的频道。多用户支持“免费获得”,因为 Slack 本来就支持多用户/多频道;只要 bot 是频道成员,任意用户在该频道发消息它都能看到并处理。

看看 main() 里调用的 join_channel(),它逻辑很简单:

  • 用 Slack client 的 conversations_list() 列出 workspace 的所有频道
  • 过滤出名称以 ai-6- 开头的频道
  • 若没有则直接返回
  • 若有多个则加入第一个
  • 若 bot 还不是成员,则用 conversations_join() 加入
  • 最后返回频道对象

代码如下:

def join_channel(client):

    """Join the first AI-6 channel if not a member already
    
    Return the channel id
    
    If there are no AI-6 channel raise an exception
    """

    all_channels = client.conversations_list()["channels"]
    ai6_channels.update(
        {
            c["id"]: c
            for c in all_channels
            if c["name"].startswith("ai-6-")
        }
    )

    if not ai6_channels:
        return
    
    channel_id = list(ai6_channels.keys())[0]
    channel = ai6_channels[channel_id]
    
    # Join channel if not a member already
    if not channel['is_member']:
        try:
            client.conversations_join(channel=channel_id)

        except Exception as e:
            print(f"Error joining channel {channel_id}: {e}")
    
    return channel

这种“自动加入特定命名约定频道”的行为,说实话主要对开发与测试有用。真实场景里你会把 AI-6 bot 邀请进特定频道。AI-6 也支持这种方式:只要 bot 在频道里,handle_message() 会响应该频道中 @提及 bot 的消息(如前所述)。

下面是把 AI-6 Slack Bot 邀请进一个它还不是成员的频道的方式:

image.png

图 6.10:显式邀请 AI-6 bot 加入频道

该频道叫 some-channel,因此 AI-6 不会自动响应其中所有消息;你必须用 @AI-6 提及它才会得到回复。我们试一下:

image.png

图 6.11:@提及 AI-6

当 AI-6 app 关闭时,它会离开所有加入过的频道(无论是自动加入还是被邀请加入):

image.png

图 6.12:AI-6 bot 离开频道

这由 leave_channels() 函数完成,逻辑也很简单:

def leave_channels(client):
    """Leave all the channels
    """
    channels = (c for c in client.conversations_list()['channels']
        if c['is_member'])
    for c in channels:
        client.chat_postMessage(
            channel=c['id'],
            text=f"Leaving channel #{c['name']}"
        )
        client.conversations_leave(channel=c['id'])

我们已经讲了 AI-6 Slack Bot 如何处理消息与管理频道。下面看它如何与 AI-6 engine 集成。

与 AI-6 引擎集成

AI-6 Slack Bot 与 AI-6 引擎之间主要有两个触点:
1)为每个 channel 创建或获取一个 engine 实例;
2)处理 AI-6 engine 发出的 tool call。

get_or_create_engine() 函数确保每个 channel 都有自己的 AI-6 engine 实例,这对多频道、多用户支持至关重要。

如果只用一个共享 engine,就很难管理每个频道的状态与记忆:上下文窗口会更快被填满;并且 LLM 会被不同频道里无关的消息搞混。

代码如下:所有频道的 engine 都存放在 engines 字典里。如果 channel_id 不在字典中,就用 utils.create_channel_engine() 创建新 engine 并写入字典,然后返回 engine:

def get_or_create_engine(channel_id):
    """Get an existing engine for a channel or create a new one."""
    if channel_id not in engines:
        # Create a channel-specific engine using the Slack utility function
        engines[channel_id] = utils.create_channel_engine(
            base_config_path=config_path,
            channel_id=channel_id,
            env_file_path=env_file_path
        )

    return engines[channel_id]

utils.create_channel_engine()(github.com/PacktPublis…)里包含了与第 4 章类似的 engine 初始化逻辑,但它还会把 channel ID 写进配置,并为该频道初始化记忆。

再看 tool call 处理逻辑:handle_tool_call() 会把每次工具调用及其响应,作为原始消息 thread 下的一条消息发出来。这样用户就能看到 AI-6 引擎如何得出回答的细节,包括工具调用与结果:

def handle_tool_call(client, channel, name, args, result):
    """Handle a tool call from the AI-6 engine."""
    # Post the tool call result as a message
    try:
        client.chat_postMessage(
            channel=channel,
            thread_ts=latest_ts,
            text=f"_Tool call: `{name}` {', '.join(args.values()) if args else ''}_\n{result}"
        )
    except SlackApiError as e:
        print(f"Error posting tool call result: {e}")

总结一下:我们讲解了 AI-6 Slack Bot 的设计与实现,以及它如何与 AI-6 引擎集成;讨论了多频道支持、消息处理与工具调用处理等关键特性。接下来我们把注意力转向 Chainlit Web UI——它更像 ChatGPT 这类 Web 聊天机器人。

使用 Chainlit 开发 Web UI

在上一节中,我们探讨了 AI-6 的 Slack Bot 接口,并看到它如何为与智能体 AI 系统交互提供一个协作式、多用户环境。Slack Bot 在团队工作流、实时通知以及与现有沟通渠道集成方面表现出色。

不过,在某些场景下,专用 Web 界面更有优势:它能更好地控制用户体验、支持更丰富的视觉元素、更适合长篇对话,并且对那些无法使用 Slack workspace 的用户而言部署更方便。这就是 Chainlit 的用武之地。本节我们将考察 AI-6 如何利用 Chainlit 构建一个类似 ChatGPT 的 Web 聊天界面,同时仍然保持对 AI-6 工具生态与智能体能力的完整访问。

Chainlit 框架概览

Chainlit(docs.chainlit.io)是一个专门用于为 LLM 应用构建聊天界面的 Python 框架。它开箱即用支持大量 UI 元素、聊天生命周期、流式响应、文件上传、设置管理、会话持久化等功能——基本上就是一个精致对话式 AI 界面所需的全部特性。它也高度可定制,允许你添加自定义 UI 元素。

Chainlit 的开发体验面向快速迭代:它运行本地开发服务器并支持热重载(hot-reloading),从而实现快速原型开发与调试。该框架还能与 OpenAI、LangChain、Hugging Face 等常见 LLM 后端无缝集成,并同时支持同步与异步工作流,以获得最大的灵活性。

下面我们看看 AI-6 如何利用 Chainlit 构建一个简单的 Web 界面来补充 Slack Bot。AI-6 的 Chainlit 应用刻意保持极简,并非为了全面展示 Chainlit 的能力;它的主要目的,是演示如何把 AI-6 后端集成到 Web UI 中,作为更高级、更精致 Web 应用的基础。

先体验一下 AI-6 Chainlit

我们从一个非常实用的真实用例开始:给 AI-6 仓库加入一个 GitHub Actions(docs.github.com/en/actions)工作流,在每次 push 新改动时自动运行全部测试。首先需要启动 Chainlit app。我们可以用便捷的 ai6.sh 脚本,并传入 chainlit 参数:

❯ ./ai6.sh chainlit
virtualenv: /Users/gigi/git/ai-six/py/venv
2025-07-19 11:28:15 - HTTP Request: GET https://api.openai.com/v1/models "HTTP/1.1 200 OK"
2025-07-19 11:28:17 - HTTP Request: GET https://api.openai.com/v1/models "HTTP/1.1 200 OK"
2025-07-19 11:28:18 - Your app is available at http://localhost:8000

AI-6 Chainlit app 现在已经在运行,我们可以通过 http://localhost:8000 访问。用浏览器打开:

image.png

图 6.13:Chainlit Web 应用

现在,让我们让 AI-6 给 AI-6 仓库添加一个 GitHub Actions 工作流:

image.png

图 6.14:初始用户提示

AI-6 给出了非常清晰且细节充分的解释,包括实际的 workflow 文件以及完成任务的所有步骤。注意这个 UI 的体验很舒适:代码有正确的语法高亮,Markdown 也被整洁地格式化:

image.png

图 6.15:详细的 LLM 响应

但这个方案有几个问题。第一,它使用了 pull_request 事件,而这个事件不会在向 main 分支常规 push 时触发;我们希望工作流也能在 push 事件上运行。第二,它使用了一些过时版本的 GitHub actions,比如 actions/checkout@v2actions/setup-python@v2,我们希望用最新版本。

我们让 AI-6 修复这些问题:

image.png

图 6.16:后续交互

看看 AI-6 实际做了什么。下面是原始 commit 和修复 commit:

❯ git log --oneline -n 2
f5198ca (HEAD -> main, origin/main, origin/HEAD) Update_workflow_for_push_event_and_latest_actions_versions
65a0c12 Add_GitHub_Actions_workflow_to_run_tests_on_backend_changes

我们再 review 一下改动:

❯ git diff HEAD~2
diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml
new file mode 100644
index 0000000..1339264
--- /dev/null
+++ b/.github/workflows/backend-tests.yml
@@ -0,0 +1,35 @@
+name: Run Tests on Backend Changes
+
+
+
+on:
+  pull_request:
+    paths:
+      - 'py/backend/**'
+  push:
+    branches:
+      - main
+    paths:
+      - 'py/backend/**'
+
+
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+
+
+
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v4
+
+
+

+    - name: Set up Python
+      uses: actions/setup-python@v5
+      with:
+        python-version: '3.x'  # Specify the python version
+
+
+
+    - name: Install dependencies
+      working-directory: py
+      run: |
+        python -m pip install --upgrade pip
+        pip install -r requirements.txt
+
+
+
+    - name: Run Tests
+      working-directory: py
+      run: |
+        python -m unittest discover -s backend/tests

现在看起来都正确了。AI-6 修复了我们指出的所有问题。这再次凸显了 human-in-the-loop 的重要性:LLM 很强,但还不完美。

接着我们就可以让 AI-6 把改动推送到 GitHub:

image.png

图 6.17:AI-6 推送改动到 GitHub

再去 GitHub 上确认新的 GitHub Actions 工作流确实已经存在:

image.png

图 6.18:GitHub Actions 工作流

这是一段使用 Chainlit 界面与 AI-6 的很棒的 session。我们完成了新增 GitHub Actions 工作流、修复问题并推送到 GitHub。Chainlit 界面为对话提供了干净、现代的呈现方式。

下面我们来看 Chainlit 界面是如何实现的,以及它如何与 AI-6 引擎集成。

配置与引擎集成

与 Slack Bot 接口类似,Chainlit Web 应用也需要配置来初始化并连接 AI-6 引擎。主程序入口在 app.py 文件中(github.com/PacktPublis…)。我们分解来看。

先看 imports。Chainlit app 从标准库的 types 模块导入 SimpleNamespace。它是个很酷的类:你可以像用字典一样用它,但可用点号访问属性。它还导入 chainlit(别名 cl),以及 chainlit.cli 模块中的 run_chainlit 函数,用于运行 Chainlit app。它还导入 pathology.path 包用于定位配置文件,并导入 shared 的 frontend.common 包里的 engine_utils 模块,用于创建 AI-6 引擎实例:

from types import SimpleNamespace

import chainlit as cl
from chainlit.cli import run_chainlit
import pathology.path

from frontend.common import engine_utils

接下来,定位配置文件并从中创建 AI-6 引擎实例。这是 AI-6 引擎初始化部分:

script_dir = pathology.path.Path.script_dir()

# Load the engine from YAML configuration
config_path = str((script_dir / "config.yaml").resolve())

# Create engine from configuration file
# Environment variables will be automatically interpolated by Config.from_file
engine, engine_config = engine_utils.create_from_config(config_path)

然后创建 app_config 对象,它是一个 SimpleNamespace 实例(docs.python.org/3/library/t…),用于保存 Chainlit app 的配置:

app_config = SimpleNamespace(
    selected_model=engine.default_model_id,
    available_models=list(engine.model_provider_map.keys()),
    enabled_tools={tool: True for tool in engine.tool_dict},
)

TOOL_PREFIX = "tool:"

该配置对象后续会在处理用户消息时使用。与 AI-6 引擎的集成就是通过前面创建的 engine 变量完成的。

应用生命周期与事件处理器

此时,我们已经准备好了 engine 和配置(app_config),应用的主要逻辑是通过调用 run_chainlit()(从 chainlit.cli 导入)并指向 app.py 文件来启动。这是一种从 Python 程序启动 Chainlit 应用的“比较笨”的方式,但用于调试:

if __name__ == "__main__":
    target = str(script_dir / "app.py")
    run_chainlit(target)

在终端中运行 Chainlit 应用的标准方式是用 chainlit CLI:

❯ chainlit run app.py

无论如何,当 Chainlit app 运行起来后,它会开始监听并处理事件。我们的 Chainlit app 处理以下事件:

  • on_chat_start:新 chat session 开始时触发
  • on_message:收到新的用户消息时触发
  • on_settings_update:设置面板中的设置被修改时触发

我们逐个看看这些 handler 是怎么工作的。

on_chat_start 事件处理器

该 handler 在 app 启动时只会被调用一次。它很简单:调用 setup_settings() 并发送我们在体验时看到的欢迎消息:

@cl.on_chat_start
async def on_chat_start():
    await setup_settings()
    await cl.Message(content="AI-6 is ready. Let's go 🚀!").send()

setup_settings() 很有意思:它用 UI widget 配置 Chainlit app 的初始设置,包括 LLM 选择和工具开关。它使用 cl.input_widget.Select 做模型选择,用 cl.input_widget.Switch 为每个工具创建开关。初始值来自我们前面创建的 app_config:

async def setup_settings():
    model_select = cl.input_widget.Select(
        id="model",
        label="LLM Model",
        values=app_config.available_models,
        initial_value=app_config.selected_model,
        initial_index=app_config.available_models.index(
            app_config.selected_model),
    )
    tool_switches = [
        cl.input_widget.Switch(
            id=f"{TOOL_PREFIX}{tool_name}",
            label=f"Tool: {tool_name}",
            initial=tool_value,
        )
        for tool_name, tool_value in app_config.enabled_tools.items()
    ]

    await cl.ChatSettings([model_select] + tool_switches).send()

这些 UI 设置显示在哪里?在用户输入框左下角有个小齿轮按钮,点开就是 Settings 面板,长这样:

image.png

图 6.19:Chainlit app Settings 面板

后面我们会看到这些设置如何被使用以及如何修改。先记住:LLM 的初始值以及工具启用状态来自 app_config,对整个设置通过 cl.ChatSettings().send() 发给 Chainlit。

到这里,Chainlit app 已初始化完成,配置了合适的 LLM 与一组启用的工具,准备开始处理消息。

on_message 事件处理器

这是核心 handler:负责处理用户消息并与 AI-6 引擎交互。每当用户输入新消息,它都会被调用。

它首先创建一个 content 为空的 cl.Message,并立即发送到 UI 中。该消息稍后会被 AI-6 引擎的响应更新:

@cl.on_message
async def on_message(message: cl.Message):
    """Process user messages and generate responses."""
    # Create a new message for the response
    msg = cl.Message(content="")
    await msg.send()

接着定义一个回调函数 on_chunk(),用于处理 AI-6 引擎流式输出的 chunk。每当引擎产生一个新 chunk,就调用该函数:

    # Define a callback function to handle streaming chunks
    async def on_chunk(chunk: str):
        # Use the Chainlit built-in streaming method
        await msg.stream_token(chunk)

下面是调用 AI-6 引擎处理消息的部分:通过 engine.stream_message() 流式返回响应。它传入用户消息内容、app_config 选择的 LLM、on_chunk 回调,以及启用工具的列表。AI-6 引擎处理过程中会多次调用 on_chunk,从而不断更新最初为空的 msg。如果出现问题,则在 Chainlit UI 中显示错误消息:

    # Stream the response
    try:
        engine.stream_message(
            message.content,
            app_config.selected_model,
            on_chunk_func=lambda chunk: cl.run_sync(on_chunk(chunk)),
            available_tool_ids=[
                k for k, v in app_config.enabled_tools.items() if v],
        )
        # Mark the message as complete
        await msg.update()
    except Exception as e:
        await cl.Message(content=f"Error: {str(e)}").send()

看看实际效果:让 AI-6 写一首 100 词关于它自己的诗,并在 on_chunk() handler 里打断点。很有意思:AI-6 以 “In digital realms where ideas entwine” 开头。LLM 选择按 token 流式输出。在 “entwine” 这个词上,它甚至把它拆成了两个 chunk(两个 token):entwine。如下图所示:

image.png

图 6.20:AI-6 正在写诗

还有一点:注意在当前版本代码里,我们没有把 on_tool_call_func 参数传给 engine.stream_message(),因此 AI-6 引擎不会向 UI 通知任何工具调用。这是刻意的:作者不想让界面被冗长的 tool calls 污染。AI-6 Slack Bot 有 thread 这个天然位置可以放 tool calls;但 Chainlit UI 没有类似结构。未来可能会加一个侧边栏,让用户选择是否打开并在那里捕获工具调用。

on_settings 事件处理器

AI-6 的 Slack 与 CLI 前端使用的是预配置好的 LLM 与工具。Chainlit app 允许用户动态选择/修改 LLM,并随时启用或禁用工具。当用户在 Settings 面板里修改设置并点击 Confirm 按钮时,会触发 on_settings() handler。它做的事很简单:用新设置更新 app_config,包括选中的 LLM 与工具启用状态:

@cl.on_settings_update
async def on_settings_update(new_settings):
    app_config.selected_model = new_settings["model"]
    for k, v in new_settings.items():
        if k.startswith(TOOL_PREFIX):
            tool_name = k.replace(TOOL_PREFIX, "")
            app_config.enabled_tools[tool_name] = v

我们来看看效果:禁用 ls 与 git 工具。这样 LLM 就无法用它们来列文件;但因为 pwd 工具仍启用,它依然应该能找到当前目录:

image.png

图 6.21:禁用工具

确实有效!AI-6 不再提供 ls 与 git 工具,因此 LLM 没有办法列出文件。

总结一下:本节我们介绍了 AI-6 的 Chainlit Web 界面:它如何初始化并与 AI-6 引擎集成、如何处理消息、以及通过动态配置实现 LLM 切换与工具开关。我们只是浅尝辄止,但已经看到它如何作为 AI-6 Slack Bot 的轻量级 Web 替代方案。

下一节我们将讨论:构建更高级、更复杂的交互式智能体界面的最佳实践。

交互式智能体界面的最佳实践

前面几节我们构建的前端接口,是与 AI 智能体交互的很好的起点。但当你要构建生产级 AI 系统时,还需要考虑一些最佳实践。这些实践可以确保用户体验顺畅、智能体意图清晰,并且系统足够健壮、可维护。

明确智能体的角色与能力

一个成功的交互式智能体界面,必须让用户立刻清楚:智能体是什么、能做什么、以及它期望什么样的输入。智能体范围的模糊会导致用户挫败和交互失败。界面应通过按钮、自动补全提示(autocomplete prompts)、帮助浮层(help overlays)等 UI 元素暴露关键能力。例如,一个文件分析智能体可以提供文件拖拽区、支持的文件类型列表,以及类似 “Summarize this CSV.” 的示例命令。

智能体也应主动传达它的限制:包括模型约束(如 token 上限)、被禁用的工具、或不支持的输入格式。在多智能体系统中,高层智能体可能自动派生子智能体,此时往往更重要的是聚焦用户可管理的高层智能体——用户可以通过预定义配置项或提示词来管理它们。

防护措施:确认、Dry-run 与安全网

AI 智能体可以调用工具,而这些工具一旦被误用可能造成很大破坏。UI 应默认采取保守姿态:对高影响动作(如覆盖文件、代码部署、删除操作)要求用户确认。确认提示不仅能防止误操作,也会迫使用户思考输入的影响与后果。

除了确认,UI 还应支持 dry-run 模式。dry run 是一种安全模拟:展示智能体“将会做什么”,但不实际执行任何操作。这在开发与测试工作流、API 集成或基础设施命令时尤其有价值。当用户能预览结果并验证正确性时,系统的信任度会提升。再结合取消或撤销(undo)能力,这些安全网会让智能体系统显得强大但不鲁莽。

同时,强烈建议在 UI 中加入一个 “Abort” 按钮,允许用户随时停止智能体。

面向轮次的透明性设计

AI 智能体以对话、轮次(turn-based)的方式运行。界面应拥抱这种交互模型,并利用它来呈现内部推理、思维链(对支持该能力的模型而言)以及工具调用。智能体采取的每一步都应在界面中有所体现:提示词、决策、工具调用、输出与中间结果。这样用户更容易理解智能体行为背后的“为什么”。

好的界面会用清晰、线性的线程展示一次交互的完整生命周期。例如,当智能体决定调用某个工具时,请求参数与响应应能在对话流中直接看到,或在可展开的面板中查看。错误、重试或降级(fallback)也应作为故事的一部分展示出来。这种设计模式能帮助用户调试、审计,并学习如何更好地提示智能体从而改善结果。

优先考虑工具调用与反馈闭环

智能体系统的一大价值在于能够使用外部工具。UI 必须让用户清楚地看到:工具何时被调用、如何被选择、以及返回了什么结果。这样就形成一个反馈闭环:用户可以评估工具使用是否合理,并在不合理时介入。

界面也应展示工具调用的元数据:时间戳、参数、返回值,以及任何错误。当工具失败时(例如网络错误或输入格式不正确),智能体应以优雅方式报告问题,并给出选项,比如重试、修改输入或跳过。允许用户“轻推”(nudge)智能体或覆盖工具选择,能让对话保持高效并与用户意图对齐。

同样重要的是:要提供一种方式让用户查看可用工具及其描述,并能为每个智能体定制工具集合。

拥抱可配置性与运行时控制

不同用户有不同偏好与信任阈值。有些用户希望智能体自由自动执行工具,有些用户则更喜欢手动确认。UI 应暴露运行时设置来控制这些行为:包括工具开关、模型切换、输出冗长度选择、以及 fallback 策略定义。

这些控制应易于访问(例如通过设置面板或聊天命令),并在合适情况下跨会话持久化。允许用户定制自己的交互风格能显著提升可用性与满意度。

面向智能体系统的日志、指标与分布式追踪

每一次用户动作、智能体决策与工具调用,都应带着元数据被记录下来。这些日志同时服务用户侧与开发者侧目标:对用户,日志可以被可视化为时间线,以支持回溯与重放历史会话;对开发者,日志用于调试、性能调优与提示词(prompt)迭代。

指标应包括:工具调用频率、平均响应延迟、会话持续时间、任务成功率、以及用户介入次数。随着时间推移,这些指标可以指导智能体行为与 UI 的持续改进。缺少埋点(instrumentation)的 UI 就是黑盒。通过对工作流每个环节进行埋点,智能体系统才能从原型演进为可靠、可审计的平台。

对复杂系统而言,LLM 特定的分布式追踪也至关重要:每一轮对话都应对应一个 span,trace 应捕获完整的 session 或整段对话。

如果你遵循这些最佳实践,你就能构建既对用户友好、也对开发者友好的交互式智能体界面:它能提供优秀体验,并让用户以自然、令人满意的方式与 AI 智能体交互。本章中我们构建的 AI-6 Slack Bot 与 Chainlit Web 界面已经体现了其中一些要素,但仍有很大提升空间。

总结

本章我们从 AI-6 的后端与内部架构转向前端 UI,重点讨论 UI 如何促成用户与自主智能体之间的有效协作。我们分析了为什么可见性、可中断性、调试能力与信任是智能体系统 UI 的核心关注点,以及为什么不同角色需要不同颗粒度的信息呈现。

随后我们探讨了 AI-6 Slack Bot 的实现,强调了诸如工具调用放入 thread、按频道隔离 engine 实例、以及实时消息处理等设计选择。Slack 的熟悉度、多用户支持与通知能力,使其非常适合协作式工作流。

最后我们分析了基于 Chainlit 的 Web 界面:它提供了干净、可定制的单用户体验。通过工具开关、模型选择与丰富的流式输出,Chainlit 补足了 Slack 的优势并提供更细粒度的用户控制。这两种接口共同为构建健壮、透明的智能体 UI 最佳实践铺垫了基础。

下一章我们将把注意力转向 Model Context Protocol(MCP):一种在工具与智能体之间共享模型状态与执行上下文的标准机制。我们将定义 MCP 是什么,探索如何实现 MCP 兼容的 server 与 client,并演示如何把 MCP 集成到你自己的智能体框架中,从而在多样化 AI 组件之间实现无缝上下文共享。