找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1618

积分

0

好友

206

主题
发表于 2026-2-12 02:26:38 | 查看: 34| 回复: 0

自从 ChatGPT 发布以来,AI 智能体的概念就持续激发着业界的想象力。它所描绘的前景十分诱人:只需给 AI 系统一个目标,它便能自主拆解问题、调用工具、搜集信息,最终综合得出答案。

围绕这一概念的框架生态已相当拥挤:LangChain、CrewAI、AutoGen、Semantic Kernel、Agent Framework……新框架层出不穷,大多声称能简化智能应用的构建。然而,其中不少仍停留在“Hello World”级别:一个智能体回答问题,最多再调用一两个工具。

构建一个真正的多智能体系统,其核心挑战并不在于让单个智能体运行起来——任何框架都能做到这一点——而在于如何使整个系统变得可维护、可测试、可扩展。本文将以一个实际项目为例(多智能体协作从 YouTube 视频中提取、摘要和整理信息),深入探讨智能体系统的架构设计。我们将聚焦以下几个关键问题:为什么智能体系统与其他复杂应用一样需要分层架构;工具(LLM接口)与服务(业务逻辑)的分离为何是智能体设计的核心洞见;领域驱动设计(DDD)的概念如何自然地映射到智能体架构;以及在编排器模式下,四个专业化的智能体如何协调工作。

本项目基于 Microsoft Agent Framework 构建(它是 Semantic Kernel 和 AutoGen 的继任者,融合了两者的优势)。不过,具体选用哪个框架并非重点,后文讨论的设计原则适用于绝大多数框架。

工程师与机器人讨论多智能体系统架构

架构挑战

现有的框架擅长帮你快速搭建演示(Demo),但在引导你走向可维护、可扩展的架构方面,却鲜有建树。例如,在许多示例代码中,LLM调用、工具集成、业务逻辑和编排逻辑之间的边界模糊不清。“关注点分离”这一软件工程中存在数十年的理念,在智能体领域似乎被框架们集体忽视了——它们更倾向于“快速上手”而非提供架构指导。相关教程优化的往往是“看,多简单!”,而不是“看,多可维护!”。

下面是一个典型的、将所有逻辑混在一起的“单体”式写法简化版:

# orchestrator.py - 智能体、工具、提示词和业务逻辑全部在一起

def run_research(query: str) -> str:

    # 搜索智能体,工具定义在行内
    def search_youtube(q: str) -> str:
        response = requests.get(f"https://youtube.com/results?q={q}")
        return parse_html_for_videos(response.text)

    search_agent = ChatAgent(
        name="SearchAgent",
        instructions="""You search YouTube. Use search_youtube to find videos.
        Return video IDs and titles as JSON.""",
        tools=[search_youtube]
    )

    # 字幕智能体,有自己的行内工具
    def get_transcript(video_id: str) -> str:
        transcript = YouTubeTranscriptApi.get_transcript(video_id)
        return " ".join([t["text"] for t in transcript])

    transcript_agent = ChatAgent(
        name="TranscriptAgent",
        instructions="Fetch transcripts using get_transcript tool.",
        tools=[get_transcript]
    )

    # 摘要智能体,提示工程嵌入其中
    summarize_agent = ChatAgent(
        name="SummarizeAgent",
        instructions=f"""Summarize cooking content. Focus on:
        - Temperatures and timing
        - Key techniques
        - Pro tips
        Format as markdown."""
    )

    # 编排逻辑与智能体调用交织在一起
    client = AzureOpenAI(api_key=os.environ["KEY"], ...)

    videos = search_agent.run(query, client=client)
    transcripts = []
    for vid in parse_json(videos)[:3]:
        text = transcript_agent.run(f"Get transcript for {vid['id']}", client=client)
        transcripts.append(text)

    summary = summarize_agent.run(f"Summarize:\n{transcripts}", client=client)

    Path(f"./outputs/{query}.md").write_text(summary)
    return summary

上面的代码用来做演示或快速验证想法完全没问题。但问题在于:如果你要继续修改、扩展或测试它呢?

为什么这是一个架构问题

LLM 调用工具本质上包含两件事:第一,用简单参数(字符串、数字)调用一个函数;第二,解释函数返回的字符串结果。

然而,真正干活的业务部分——搜索 YouTube、解析 HTML、处理错误——要复杂得多。它涉及配置管理、错误处理、重试逻辑,并且返回的是包含多个字段的结构化对象。

这两件事属于不同的关注点。LLM 需要简单的字符串交互,而应用程序则需要合理的抽象层级。把它们搅在一起,就像把 SQL 查询直接写在视图层里:虽然能运行,但从架构角度看是错误的。

将这两个职责分离开来,可测试性、可复用性以及代码清晰度都会随之而来。

如何分离?

工具 = LLM 接口

工具是 LLM 与应用程序之间的一层薄适配器。它接受简单参数(字符串、数字、布尔值),调用对应的服务,并将结果格式化成 LLM 能够理解的字符串。工具本身应该是无状态的。

# tools/youtube.py

async def fetch_video_transcript(
    video_id: Annotated[str, Field(description="YouTube video ID")]
) -> str:
    """Fetch the transcript for a YouTube video.

    Returns the full transcript text with video metadata.
    """
    result = await fetch_transcript(video_id)  # calls service

    ## Format for LLM
    return f"Transcript for '{result.metadata.title}':\n\n{result.transcript.full_text}"

工具没有做的是:没有配置管理,没有复杂的返回类型,没有业务逻辑。它只做一件事:调用服务、格式化结果。它扮演的是纯粹的适配器角色。

服务 = 业务逻辑

服务才是真正实现业务逻辑的地方。它们是带有配置、可复用的类,返回丰富的领域对象(模型),可以从 CLI、测试脚本或其他服务的任何地方调用,并且可能维护状态或持有连接。

# services/youtube.py

class YouTubeTranscriptFetcher:
    """Fetches transcripts from YouTube videos."""

    def __init__(self, proxy_url: str | None = None):
        self.proxy_url = proxy_url

    async def fetch(
        self,
        video_id: str,
        languages: list[str] | None = None
    ) -> TranscriptResult:
        """Fetch transcript with full metadata.

        Returns a TranscriptResult containing the transcript text,
        video metadata, and language information.
        """
        # Real implementation with error handling, retries, etc.
        raw_transcript = await self._fetch_from_api(video_id, languages)
        metadata = await self._fetch_metadata(video_id)

        return TranscriptResult(
            metadata=metadata,
            transcript=Transcript(
                full_text=self._format_transcript(raw_transcript),
                segments=raw_transcript,
                language=self._detect_language(raw_transcript),
            ),
        )

复杂性应该被封装在这里。配置、缓存、错误处理、重试、类型化的返回值——这些都属于服务层的职责。即使脱离 LLM,服务本身也是完全可用的。

流程

当 LLM 决定获取字幕时,完整的调用链如下所示:

LLM decides to call "fetch_video_transcript"
    ↓
tools/youtube.py::fetch_video_transcript(video_id)
    ↓
services/youtube.py::YouTubeTranscriptFetcher.fetch(video_id)
    ↓
Returns TranscriptResult object
    ↓
Tool formats as string for LLM

为什么这很重要

首先来看可复用性。服务可以直接从 CLI、测试脚本或批处理任务中调用,完全绕开 LLM:

# 从 CLI 使用,完全绕过智能体
@click.command()
def download_transcript(video_id: str, output: str):
    fetcher = YouTubeTranscriptFetcher()
    result = fetcher.fetch(video_id)
    Path(output).write_text(result.transcript.full_text)

# 在测试中使用,无需模拟 LLM
def test_fetcher_handles_unavailable_videos():
    fetcher = YouTubeTranscriptFetcher()
    with pytest.raises(TranscriptDisabledError):
        fetcher.fetch("video_with_disabled_transcript")

# 在批处理中使用
async def process_videos(video_ids: list[str]):
    fetcher = YouTubeTranscriptFetcher()
    results = await asyncio.gather(*[fetcher.fetch(id) for id in video_ids])
    return results

其次是可测试性。服务返回的是类型化的对象,断言可以写得非常清晰。而工具返回的是格式化后的字符串,验证起来则麻烦得多:

# 测试服务 - 清晰的断言
def test_fetcher_returns_transcript():
    result = fetcher.fetch("abc123")
    assert result.transcript.full_text
    assert result.metadata.video_id == "abc123"
    assert result.transcript.language in ["en", "en-US"]

# 测试工具 - 需要字符串解析
def test_tool_formats_correctly():
    output = fetch_video_transcript("abc123")
    assert "## " in output  # Has title?
    assert "Transcript" in output  # Has section header?
    # Much harder to validate structure

最后是关注点分离。工具代码负责“如何呈现给 LLM”,服务代码负责“如何真正干活”。如果 YouTube API 发生变化,你只需要修改 services/youtube.py。如果想改变输出格式,也只需调整工具层即可。

分层架构

工具与服务的分离仅仅是一条关键边界。一个完整的多智能体系统需要更多的结构。经过反复实践,最终我们落地了一个六层架构,每一层都有明确的职责。如果你熟悉领域驱动设计(DDD),应该会感到眼熟:

AI代理分层架构示意图

在实际代码中,它的结构是这样的:

# presentation/cli.py - 表示层
@click.command()
def search(query: str):
    """Search for videos on YouTube."""
    agent = create_search_agent()
    result = agent.run(query)
    click.echo(result)
# agents/search.py - 智能体层(仅配置)
def create_search_agent() -> ChatAgent:
    """Factory function that creates a Search Agent."""
    return ChatAgent(
        chat_client=get_chat_client(),
        name="SearchAgent",
        instructions=SEARCH_AGENT_INSTRUCTIONS,
        tools=[search_youtube_formatted],
    )
# tools/youtube.py - 工具层(薄 LLM 适配器)
async def search_youtube_formatted(query: str) -> str:
    """Search YouTube for videos matching the query."""
    results = await search_youtube(query)  # calls service
    return format_for_llm(results)         # formats for LLM
# services/youtube.py - 服务层(业务逻辑)
async def search_youtube(query: str) -> list[VideoResult]:
    """Search YouTube - returns rich domain objects."""
    url = build_search_url(query)
    html = await fetch_html(url)  # calls infra
    return parse_video_results(html)
# models/youtube.py - 模型层(领域对象)
@dataclass
class VideoResult:
    video_id: str
    title: str
    channel: str
# infra/http_client.py - 基础设施层(HTTP 传输)
async def fetch_html(url: str, timeout: float = 10.0) -> str:
    """Fetch HTML content with browser-like headers."""
    async with httpx.AsyncClient() as client:
        response = await client.get(url, headers=DEFAULT_HEADERS, timeout=timeout)
        response.raise_for_status()
        return response.text

每一层各司其职:智能体配置行为,工具作为 LLM 适配器,服务实现核心逻辑,模型定义数据结构。测试也变得更为直接:在层与层之间的边界进行模拟(Mock),无需深入内部细节。

DDD 与智能体架构的映射并非生搬硬套,而是自然浮现的,因为智能体系统与其他复杂应用面临的是同一组关注点:

系统分层架构与DDD角色对应表

tools/ 层作为防腐层(Anti-Corruption Layer)的对应关系尤为精准。在 DDD 中,防腐层用于保护领域模型不被外部系统的概念“污染”。在这里,它的作用完全相同——隔离了 LLM 的接口需求,在“LLM 能够推理的字符串”和“代码使用的丰富领域对象”之间进行翻译。

调用流程应严格遵循自上而下的方向:智能体使用工具,工具调用服务,服务操作模型。这种约束迫使你仔细思考每一段代码应该归属的层次。

何时需要这种架构

对于简单项目来说,这算不算过度设计?某种程度上是的。但在以下几种情况下,值得从一开始就采用这种架构:

  1. 项目需要投入生产环境。
  2. 你在使用 AI 编码助手(如 GitHub Copilot、Claude Code,它们在结构清晰的代码上表现更佳)。
  3. 需要多人协作开发。
  4. 需要进行正规的测试。
  5. 业务领域本身复杂(涉及多个外部 API、复杂的业务逻辑、丰富的数据模型)。
  6. 预期系统会持续扩展。

智能体系统中的“混乱”往往是渐进发生的。一开始为了图快而使用内联工具,随后发现需要复用某个功能,接着又要测试某个部分,再后来需要添加错误处理……每一次修改,都让代码变得更加纠缠不清。

AI 编码助手时代的架构

还有一个越来越重要的考量维度:结构清晰的代码与 AI 编码助手能够更好地协作。

GitHub Copilot、Cursor、Claude Code 等工具已成为开发工作流的标准配置。一个显而易见的规律是,面对结构良好的代码,它们的表现远胜于面对一个全新项目或纠缠不清的代码库。如果再辅以适当的文档提供上下文,效果会更好。

例如,当你要求 Claude Code “实现按最短时长过滤搜索结果的功能”时,它能精准地定位到 services/youtube.py。服务层的边界清晰、接口有类型提示、模式一致。AI 无需理解整个系统,就能推理出应该如何修改。

反之,如果工具定义散落在编排代码中,AI 就需要先弄清楚工具在哪里定义、与智能体如何耦合、修改是否会影响其他部分、依赖关系如何流转。

让代码对人类可维护的那些软件工程原则,同时也让代码对 AI 助手变得可导航。清晰的边界使 AI 能够聚焦于单一层次,而无需理解整个技术栈。一致的模式让 AI 在学会后可以一致地应用。类型提示不仅仅是文档,它们也是 AI 生成正确代码的约束。单一职责原则使得 AI 在修改一个服务时,无需推理多个相互关联的关注点。

这并非为了“对 AI 友好”而牺牲设计,恰恰相反,正是这些设计让代码对 AI 系统也变得易于理解。

随着 AI 编码助手日益普及,架构纪律的价值只会越来越大。最能从 AI 辅助中获益的,永远是那些本来就结构良好的代码库。混乱的代码库只会继续混乱下去,因为 AI 会放大已有的模式——无论好坏。

测试

分层架构带来的一个天然优势是可测试性。层与层之间的边界清晰,测试策略也随之变得直截了当。

我们遵循的原则是:在系统边界进行模拟(Mock),而不是在系统内部

┌─────────────────────────────────────────────┐
│  agents/  →  tools/  →  services/           │  ← Test with REAL code
└─────────────────────────────────────────────┘
                             ↓
                     ┌─────────────────┐
                     │ External APIs   │  ← MOCK here
                     │ - YouTube API   │
                     │ - Azure OpenAI  │
                     └─────────────────┘

不要模拟(Mock)你自己的服务。例如,测试 TranscriptSummarizer 时,可以注入一个模拟的 OpenAI 客户端,但让服务本身的逻辑真实执行。测试存储功能时,可以使用临时目录,但运行真实的文件 I/O 操作。

这样做的好处是获得更高的信心(因为执行了真实的代码路径),更少脆弱的测试(减少了 Mock 的维护成本),并且能够捕获纯单元测试可能遗漏的集成问题。

领域驱动的组织方式

有了分层结构,下一个问题是:每个层内部应该如何组织代码?以 services/ 包为例,同样的思路适用于所有层次,不过不同层次可能会得出不同的结论。

这里,DDD 中的限界上下文(Bounded Context) 概念可以直接适用。

我们有两个选项:

选项 A:按技术功能拆分

services/
├── search.py           # YouTube search
├── transcript.py       # Transcript fetching
├── summarizer.py       # AI summarization
└── storage.py          # Persistence

选项 B:按业务限界上下文拆分

services/
├── youtube.py          # Search + transcripts (same context)
├── summarizer.py       # AI summarization
└── storage.py          # Persistence

我们选择了 B

限界上下文

在领域驱动设计中,限界上下文是指一个边界,在此边界内,特定的术语具有一致的含义。“YouTube”就是一个典型的限界上下文——“video_id”指的是 YouTube 视频 ID,“channel”指的是 YouTube 频道,“transcript”指的是 YouTube 字幕。

搜索和获取字幕功能共享同一个 API 接口、同一组领域概念(视频、频道)以及同一类错误条件(速率限制、视频不可用)。将它们放在一起可以获得:

  • 内聚性:调试字幕问题时无需翻阅多个文件。
  • 可替换性:如果需要增加 Vimeo 支持,只需创建一个 services/vimeo.py 实现相同的接口,系统的其余部分无需改动。
  • 可发现性:“YouTube 相关的逻辑在哪里?”答案是简单的 services/youtube.py
  • AI 可理解性:一致的领域语言让 AI 助手能够共享你的词汇表,而无需猜测。

判定准则

在决定代码归属时,可以问自己一个问题:“如果把这个外部系统换掉,哪些代码需要跟着改变?

每个限界上下文就是一个潜在的替换点。如果更换一个外部系统需要修改多个文件,那么边界很可能划分错了。

这个限界上下文的原则贯穿了领域层和防腐层——在 services/tools/models/ 中都存在一个 youtube.py,用于组织所有与 YouTube 相关的功能。这使得代码导航变得可预测:“YouTube 逻辑在哪?”在任何一层中寻找 youtube.py 即可。

对于 AI 辅助开发还有一个附带好处:当 LLM 需要理解或修改 YouTube 相关代码时,一致的命名让它无需猜测就能找到正确的文件。此外,规模稍大但内聚性强的模块并非坏事——模型在读取单个文件时就能获得完整的上下文,这比从一堆小文件中拼凑信息要好得多。

智能体设计:单一职责

确定了层次结构和领域组织方式后,我们来看智能体本身的设计。

我们为每个智能体赋予单一且明确的职责

各智能体职责划分表

这可能看起来有些死板——TranscriptAgent 明明已经拿到了字幕文本,为什么不顺便做个摘要呢?

原因在于可预测性和可调试性。当系统出现问题时,可以快速定位:摘要质量差?查 SummarizeAgent。字幕拉取失败?查 TranscriptAgent。搜索结果不相关?查 SearchAgent。一个问题,一个入口。

为什么不用一个 YouTubeAgent?

你可能注意到了一个看似矛盾的地方。我们刚才主张在 services/tools/models/ 层都按限界上下文组织,每层都有一个 youtube.py。那为什么不创建一个同时处理搜索和字幕的 YouTubeAgent 呢?

这是因为不同层次的组织逻辑不同。领域层(服务、模型)和防腐层(工具)是按照外部系统划分的,这些层包含了“video_id”、“channel”等具体的领域概念,按限界上下文分组使得系统更易于理解和替换。然而,智能体属于编排层:它们定义的是任务和角色,而非系统边界。SearchAgent 的任务是“找视频”,TranscriptAgent 的任务是“拉字幕”,它们只是恰巧使用了同一个外部系统。

同样,我们不会把 SummarizeAgent 称为“AzureOpenAIAgent”,尽管它确实使用了 Azure OpenAI。智能体的身份取决于它“做什么”,而不是它“用了什么”。一个任务,对应一个智能体,也就对应了一个出问题时需要检查的地方。

编排器模式

四个职责单一的智能体需要被协调起来工作,这就是 OrchestratorAgent 的职责:

用户请求
    ↓
编排器(决定做什么)
    ↓
    ├── "需要搜索" → SearchAgent
    ├── "需要字幕" → TranscriptAgent
    ├── "需要摘要" → SummarizeAgent
    └── "需要保存" → WriterAgent

编排器维护对话记忆,知道哪些内容已经被缓存(通过上下文注入),将具体工作委托给专家智能体,自己从不直接调用 YouTube 或 OpenAI 的 API。

这种分离意味着每个专业智能体都可以被独立测试,输入和输出清晰明了。

智能体定义

定义一个智能体出乎意料地简单:

#agents/search_agent.py

SEARCH_AGENT_INSTRUCTIONS = """You are a YouTube Search Agent. Your job is to find relevant YouTube videos based on user queries.

When asked to search:
1. Use the search_youtube tool to find videos
2. Return the results clearly formatted
3. Highlight which videos seem most relevant to the query

You only search - you do not fetch transcripts or summarize. Other agents handle those tasks."""

def create_search_agent() -> ChatAgent:
    """Factory function that creates a Search Agent."""
    return ChatAgent(
        chat_client=get_chat_client(),
        name="SearchAgent",
        instructions=SEARCH_AGENT_INSTRUCTIONS,
        tools=[search_youtube_formatted],
    )

智能体的指令被提取为模块级常量(也可以从外部文件如 prompts/search_agent.txt 加载,这样迭代提示词时无需改动 Python 代码)。工具则来自 tools/ 层的函数(这些函数再去调用服务)。智能体完全不知道 YouTube API 的存在——它只调用工具。

编排器的实现

编排器遵循相同的模式,只不过它的“工具”是委托给其他智能体的函数:

class OrchestratorAgent:
    """Coordinates sub-agents for YouTube research tasks."""

    def __init__(self) -> None:
        self._agents: dict[str, ChatAgent] = {}
        # Agent factory registry for lazy initialization
        self._agent_factories = {
            "search": create_search_agent,
            "transcript": create_transcript_agent,
            "summarize": create_summarize_agent,
            "writer": create_writer_agent,
        }

    def _get_agent(self, name: str) -> ChatAgent:
        """Get or create an agent by name (lazy initialization)."""
        if name not in self._agents:
            self._agents[name] = self._agent_factories[name]()
        return self._agents[name]

    async def _delegate(self, agent_name: str, request: str) -> str:
        """Delegate a request to a sub-agent."""
        agent = self._get_agent(agent_name)
        result = await agent.run(request)
        return result.text

    async def ask_search_agent(self, request: str) -> str:
        """Delegate a search request to the Search Agent."""
        return await self._delegate("search", request)

    # ... similar for transcript, summarize, writer

    def get_orchestrator(self) -> ChatAgent:
        return ChatAgent(
            chat_client=get_chat_client(),
            name="Orchestrator",
            instructions=ORCHESTRATOR_INSTRUCTIONS,
            tools=[
                self.ask_search_agent,
                self.ask_transcript_agent,
                self.ask_summarize_agent,
                self.ask_writer_agent,
            ],
        )

这里使用类而非简单的工厂函数是刻意的:编排器需要维护状态,具体来说是一个延迟初始化的子智能体缓存。这避免了每次委托都重新创建智能体,将初始化成本推迟到首次使用时。

编排器的“工具”本质上是这些委托函数。当 LLM 决定进行搜索时,它会调用 ask_search_agent,后者运行 SearchAgent 并返回结果。编排器拿到结果后,再决定下一步做什么。

这就是中心辐射(Hub-and-Spoke) 模式:

                      ┌─────────────┐
                      │ Orchestrator│
                      │   (LLM)     │
                      └──────┬──────┘
                             │
          ┌────────────┬─────┴─────┬───────────┐
          │            │           │           │
          ▼            ▼           ▼           ▼
      ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌─────────┐
      │ Search  │ │Transcript│ │Summarize│ │  Writer │
      │  Agent  │ │  Agent   │ │  Agent  │ │  Agent  │
      └─────────┘ └──────────┘ └─────────┘ └─────────┘

所有交互都流经中心节点。编排器逐步积累上下文,维护着完整的对话历史。

上下文注入

一个容易忽略但至关重要的模式是:编排器需要知道哪些字幕已经被缓存了,才能做出明智的决策。Microsoft Agent Framework 提供了 ContextProvider 基类,通过实现 invoking() 方法,可以在每次 LLM 调用之前注入上下文:

from agent_framework._memory import Context, ContextProvider

class TranscriptContextProvider(ContextProvider):
    """Provides context about stored transcripts to the orchestrator."""

    async def invoking(self, messages, **kwargs) -> Context:
        """Called before each LLM invocation."""
        video_ids = self._storage.list_videos()

        if not video_ids:
            return Context(instructions="No transcripts currently stored.")

        lines = ["You have these transcripts available:"]
        for vid in video_ids:
            stored = self._storage.load(vid)
            if stored:
                status = "summarized" if stored.summary else "not summarized"
                lines.append(f"- {stored.metadata.title} ({vid}): {status}")

        return Context(instructions="\n".join(lines))

框架在每次发起 LLM 请求前都会调用 invoking() 方法,返回的 Context 会被合并到智能体的指令中。

这与对话记忆(Conversation Memory)是两回事。对话记忆记录的是用户与智能体之间的对话历史,由框架自动管理(通常通过线程或会话机制)。传递给 invoking()messages 参数已经包含了这个历史。

ContextProvider 解决的是另一个问题:注入对话之外的领域状态。存储层可能已将字幕持久化到磁盘,但 LLM 并不知道这一点,除非我们主动告诉它。查询存储、将结果格式化成指令,这弥合了应用状态与 LLM 上下文窗口之间的鸿沟。

简而言之,对话记忆回答的是“我们聊了什么”,而领域上下文回答的是“我们有什么资源可用”。框架负责前者,开发者需要自己负责后者。

这样一来,编排器就能做出如下推理:“用户要求生成摘要,而相关字幕已经缓存好了,那么跳过获取步骤,直接委托给 SummarizeAgent。”

输出结果

最终系统生成的 Markdown 文件示例如下:

# Pork Loin Roast on a Kamado (YouTube-Technique Guide)

**Date:** 2025-01-11
**Source:** YouTube technique summaries (videos linked below)

## Key targets (temps & doneness)

- **Pit / dome temp (indirect smoking):** **250–275°F** (121–135°C)
- **Internal temp targets (pork loin):**
- **Pull at 140–145°F** (60–63°C) for juicy slices
- If you prefer more done: **150°F** (66°C)
- **Rest:** **10–20 minutes** (loosely tented)

## Recommended kamado setups

### Setup A — Indirect "smoke-then-finish" (most consistent)
1. **Charcoal:** quality lump; add 1–3 chunks of mild fruit wood
2. **Heat deflectors:** installed for indirect cooking
3. **Target pit temp:** stabilize at **250–275°F**
...

## Video references
- **Fork & Embers** — Pork loin roast method
- **Chuds BBQ** — Temp-control + finishing approach

多个 YouTube 视频的信息被综合成了一份连贯、可直接操作的参考文档。SearchAgent 找到相关视频,TranscriptAgent 获取内容,SummarizeAgent 提炼关键信息,WriterAgent 保存结果。各个智能体各司其职。

迭代优化

由于编排器维护着对话历史,因此用户可以持续对话以细化结果:

User: Can you add a section comparing direct vs indirect cooking methods?
User: The temperatures seem low - can you check if Chuds mentions a hotter approach?
User: Save a version without the glaze instructions for my friend who doesn't like sweet.

后续的请求可以直接复用已缓存的字幕,无需重新从 YouTube 拉取。编排器记得自己拥有什么,推理还需要什么,并按需进行委托。这种对话循环才是智能体模式真正出彩的地方——系统能够根据反馈进行调整,而无需每次都从头开始。

灵活性的代价

编排器模式有一个重要的权衡,只有在多次运行后才会显现:方差(Variance)

上面展示的整齐的顺序流程,只是众多可能执行路径中的一种。同样的请求再运行一次,可能会走一条完全不同的路线。

对同一个请求进行多次基准测试后,我们发现 LLM 的调用次数在 17 到 34 次之间波动。相同的输入,编排器 LLM 每次做出的战术决策却可能不同:

智能体决策点与观察到的方差表

打开详细日志,就能看到差异:

# Run A (17 calls) - 最小化方法
SearchAgent called with: Kamado pork loin Fork and Embers
SearchAgent called with: Chuds BBQ pork loin kamado
TranscriptAgent called with: Fetch transcript for video FsbwQI-EI-k...
TranscriptAgent called with: Fetch transcript for video 2AF1ysZ8eEA...
TranscriptAgent called with: Fetch transcript for video fI86yXKlnQA...
WriterAgent called with: Write a markdown file...  # 跳过了摘要步骤!

# Run B (25 calls) - 彻底的方法
SearchAgent called with: Find YouTube videos where Fork and Embers...
SearchAgent called with: Find YouTube videos where Chuds BBQ...
SearchAgent called with: Find top YouTube videos about cooking pork loin...
TranscriptAgent called with: ...
SummarizeAgent called with: From the provided transcripts, extract...
WriterAgent called with: ...

Run A 认为 WriterAgent 可以直接从原始字幕综合出结果。Run B 则多走了一步摘要。两种路径都产生了有效的输出,但成本和质量可能不同。

你可能会想:“把 LLM 的 temperature 参数设成 0 不就能解决了吗?”

面对方差,我们的第一反应自然是降低 LLM 的 temperature 以追求确定性行为。我们测试了:

不同Temperature设置下的调用次数范围与标准差

所有测试运行都设置了固定的随机种子(42)。

然而,即使将 temperature 设为 0 并固定随机种子,调用次数仍然有 10 次左右的波动(25 到 35 次)。这种不可预测性的根源并非采样的随机性,而在于 LLM 在每次运行时做出了不同的、但逻辑上都合理的策略选择:发起几个并行搜索(1个、2个还是3个)、按视频分别摘要还是合并摘要、是否要跳过摘要步骤直接让 Writer 进行综合。

这种方差是架构层面的特性。要削减它,要么将每个智能体的职责范围卡得极其严格,以缩小决策空间;要么干脆采用提前规划好的执行路径,消除运行时的决策。这些替代方案将在后续的文章中探讨。

这并非一个“Bug”,而是让 LLM 在运行时决定工作流所固有的代价。编排器获得了随机应变的灵活性,付出的代价就是行为的不可预测性。对于对话式交互场景,这种权衡通常是值得的。但对于需要高度可预测性的批处理任务,则可能需要考虑其他方法。

总结

本文的初衷是想验证一件事:多智能体系统能否像其他严肃的软件系统一样进行架构设计。通过探索编排器模式,答案是:完全可以。

采用的方法本身谈不上新颖。分层架构、关注点分离、领域驱动设计,这些都是软件工程中的经典原则。但我们可以看到,当它们映射到智能体系统时,契合度非常高。

工具和服务承担着根本不同的职责。工具在 LLM 的世界(简单参数、字符串输出)与领域的世界(丰富的对象、业务逻辑)之间进行翻译。将它们清晰地分离开,系统就会自然而然地变得清晰、可测。

我们可以将智能体理解为配备了自然语言接口和 LLM 组件的软件系统。几十年来积累的软件工程纪律仍然适用,关键在于想清楚各个边界应该划在哪里。

本文分享了一种构建可维护多智能体系统的架构思路。在实践中,无论是选择框架还是设计模式,都离不开与开发者社群的交流与碰撞。如果你对这类人工智能或更广泛的软件开发话题感兴趣,欢迎到云栈社区参与讨论,那里有更多开发者分享的实战经验和开源项目。




上一篇:C语言 auto 关键字详解:理解自动存储期与变量生命周期
下一篇:突破GEMM瓶颈:d-PLENA NPU专为扩散大模型采样优化,实现RTX A6000 2.53倍加速
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 11:43 , Processed in 0.829055 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表