使用 Docker 构建可落地运行的 AI 系统——多模型与多 Agent 架构

0 阅读15分钟

在第 7 章中,你已经构建了能够使用 tools 并做出 decisions 的 agents。但有一件很有意思的事情:我们对所有事情都使用了同一个 model。这当然能工作,但并不总是最聪明的选择。可以这样想:你不会雇一个脑外科医生来修剪草坪,也不会让园丁去做外科手术。不同的工作需要不同的工具。AI models 也遵循同样的原则。

本章讨论的是如何更聪明地使用你的 models。你将学习什么时候该使用 small、fast model,什么时候需要动用 “big guns”。我们会构建一些 systems,让多个 models 和 agents 协同工作,各自完成自己最擅长的事情。是的,在这个过程中你还能节省大量成本。

本章将覆盖以下主要主题:

  • Understanding the single model trap and why it costs you money
  • Building a complexity-based router
  • Building a multi-agent research assistant
  • Scaling multi-agent systems for production traffic

到本章结束时,你将拥有三个可以运行的 systems:一个能够把 tasks 发送给正确 model 的 smart router;一个真正能 search the web 的 multi-agent research assistant;以及能够处理 real production traffic 的 scaled systems。

Technical requirements

为了跟随本章 examples,你需要在自己的机器上准备好以下 tools 和 accounts。

Software:

Docker Desktop 4.42.0 or later:运行多个 models 时需要 Model Runner 和 multi-model features。请从以下地址下载:

https://www.docker.com/products/docker-desktop/

可以在 terminal 中运行 docker version,或者在 Docker Desktop 中查看 Settings > About 来验证版本。

Python 3.11 or later:router 和 agent implementations 需要它。所有 examples 都使用 Flask 来提供 HTTP endpoints。使用以下命令验证:

python --version

Git:用于 clone 本章 code repository。使用以下命令验证:

git --version

curl:本章中会反复用于测试 API endpoints。macOS 和大多数 Linux distributions 已预装。

Hardware:

至少 16 GB RAM 的机器,推荐 32 GB。Simultaneously 运行多个 models,尤其是测试包含四个 agents 的 research assistant 时,需要大量 memory。如果你同时运行所有 examples,32 GB 会提供更舒适的 headroom。

15 GB free disk space,用于 container images 和 model files。多个 models(granite-nano、granite-micro、qwen3、smolvlm)会被下载。运行以下命令检查当前 Docker disk usage:

docker system df

Accounts and API keys:

Firecrawl API key(research assistant 必需) :multi-agent research assistant 使用 Firecrawl 进行 web search。请在以下地址注册并生成 API key:

https://firecrawl.dev

Free tier 包含足够用于 testing 的 searches。

Knowledge prerequisites:

本章直接建立在第 3 章、第 6 章和第 7 章之上。你应该熟悉用于运行 local LLMs 的 Docker Model Runner(第 3 章)、用于 tool access 的 MCP Gateway configuration(第 6 章),以及用于理解 agents 如何 coordinate 的 autonomous agent patterns(第 7 章)。理解前面章节中的 Docker Compose services、environment variables 和 networking 是非常必要的。

熟悉 Flask 会帮助你理解 router implementations,不过即使 Flask 不是你的主要 framework,这些 code 也足够 straightforward,仍然可以跟着理解。对 REST APIs 和 Redis 有基本了解,也有助于理解 multi-agent examples。

本章 code examples 可在以下位置获取:

https://github.com/PacktPublishing/Operational-AI-with-Docker/tree/main/chap-08

Understanding the single model trap and why it costs you money

在开始构建 routers 和 multi-agent systems 之前,我们先讨论为什么你一开始就需要它们。大多数 teams 都会陷入一个 common trap,而这个 trap 会同时消耗 money 和 performance。理解这个 trap,会为本章后续所有内容提供动机。

Why one model for everything fails

让我讲一个我听说过的 team 的故事。他们构建了一个 customer support system,并且对所有事情都使用 Claude Opus——reading tickets、categorizing tickets、writing responses、extracting data,全部都用它。Opus 非常强大,但也非常昂贵。他们每个月花费 15,000 美元。

问题在于:他们的大多数 tickets 其实都很简单。“Where’s my order?” “How do I reset my password?” 这类问题根本不需要 frontier model。一个 smaller、faster model 就可以很好地处理它们。

后来他们加入了 smart routing——简单任务使用 small model,只有 complex issues 才使用 Opus——账单从每月 15,000 美元降到了 3,000 美元。质量一样,成本低得多。

这就是我们称为 “everything model trap” 的问题。你选择一个 powerful model,然后把所有东西都 route 到它那里。一开始这看起来很简单,但最终你会遇到:

Wasted money on simple tasks:用 GPT-4 回答 “What’s your return policy?” 就像雇一个 surgeon 来贴创可贴。Big model 给出的答案和 small model 一样,但每个 token 的成本高出 10–50 倍。

Slower responses:使用 big models 生成 tokens 需要更长时间。如果 small model 可以在 100 milliseconds 内回答,为什么要让 users 等 850 milliseconds?对于 simple questions 来说,这些额外 latency 会累积成更差的 user experience。

Vendor lock-in:所有东西都依赖一个 API。如果它 down 了、rate-limited 了,或者涨价了,你就被卡住了。Multi-model architecture 会给你 flexibility 和 fallback options。

解决方案不是换成另一个 single model,而是针对每个 job 使用 right model。

Figure 8.1 直观展示了这个 trap。当你把所有事情都 route 到一个 single general-purpose model 时,会同时撞上三堵墙:model 缺少 domain-specific tasks 所需的 specialized capabilities;你为 simple work 支付 premium prices;整个 system 都依赖单一 provider 的 availability。

image.png

Figure 8.1:对所有事情都使用一个 model 的问题

如图所示,把所有 tasks 都 route 到 GPT 或 Claude 这样的 single general-purpose model,会造成三个叠加问题:specialized tasks 缺少 domain-specific capabilities;simple work 成本更高、performance 更慢;并且出现 vendor lock-in 和 single point of failure。

Understanding model sizes

在深入 code 之前,我们先讨论本章会使用的 models。Docker Desktop 通过 Model Runner 为你提供多个 models,它们可以根据 size 和 capabilities 分为三类。

可以把 model sizes 想象成 calculators。Basic calculator 可以快速处理 everyday math。Scientific calculator 可以做更复杂的工作,但占用更多空间。Laptop 可以执行 sophisticated calculations,但需要更多 power 和 time。

image.png

Figure 8.2:用 calculator analogy 理解 model sizes

这三个 categories 在实践中大致如下:

Small models,例如 granite-nano,大约有 1–3 billion parameters。它们通常在约 100 milliseconds 内响应,非常适合 straightforward questions,例如 categorization、simple Q&A 和 formatting。运行成本几乎可以忽略不计。

Medium models,例如 granite-micro,大约有 7–15 billion parameters。它们通常在约 200 milliseconds 内响应,适合大多数 real-world tasks,包括 summarization、moderate reasoning 和 content generation。它们在 speed 和 capability 之间提供了良好平衡。

Large models,例如 qwen3,拥有 8 billion 到超过 32 billion parameters。它们大约需要 850 milliseconds,可以处理 complex reasoning、multi-step analysis 和 nuanced tasks。它们更昂贵,但在你真正需要时非常强大。

我刚开始使用不同 model sizes 时,最让我惊讶的是:small models 比你想象中好得多。对于大约 70% 的 typical requests,nano model 可以给出和 much larger model 一样质量的结果。你只需要 intelligent routing。

现在你已经理解了为什么 single-model architectures 会失败,接下来看看如何修复它们。

Two ways to route requests

本章会构建两种不同的 routing systems,每一种解决一个不同的问题:

Task-based routing 回答的问题是:“这是什么类型的工作?” 你检查 request,并根据 task type 进行 route。Code generation 发给 code-specialized model;Image analysis 发给 vision model;General questions 发给 text model。每个 model 都针对自己的 specific domain 进行了优化。

Complexity-based routing 回答的问题是:“这有多难?” 你检查 request,并根据 difficulty 进行 route。Simple questions 交给 small、fast model;Medium complexity 交给 mid-size model;Really complex reasoning 交给 big model。这会基于实际需要的 capability 来优化 cost 和 latency。

你也可以同时使用这两种 approaches。先按 task type route,选择正确 domain;再在该 domain 内按 complexity route,选择合适 size。本章会展示如何组合它们。

现在开始构建。

Building a complexity-based router

Simple questions 使用 fast、cheap model。Hard questions 使用 big guns。Complexity-based routing 的关键,是给 prompt 的难度打分。

Measuring complexity

下面是一种 simple heuristic approach,实践中效果很好:

def calculate_complexity(prompt):
    """Score from 0 (simple) to 1 (complex)"""
    score = 0.0
   
    # Length matters
    word_count = len(prompt.split())
    if word_count > 50:
        score += 0.3
    elif word_count > 20:
        score += 0.2
   
    # Questions about multiple things are harder
    question_words = ['what', 'why', 'how', 'when', 'where', 'compare']
    questions = sum(1 for word in question_words if word in prompt.lower())
    if questions > 1:
        score += 0.3
   
    # Technical terms indicate complexity
    technical_terms = ['architecture', 'implement', 'design', 'analyze',
                       'optimize', 'integrate', 'configure']
    if any(term in prompt.lower() for term in technical_terms):
        score += 0.2
   
    # Explicit complexity markers
    if 'complex' in prompt.lower() or 'detailed' in prompt.lower():
        score += 0.2
   
    return min(score, 1.0)  # Cap at 1.0

这个 scoring 是 heuristic-based,不是 perfect。但对于大多数 use cases 来说已经足够好。如果你需要更高 accuracy,可以基于 simple versus complex queries 的 labeled examples 训练一个 small classifier;但建议先从 heuristics 开始,只有当数据证明你确实需要时,再增加 complexity。

一旦有了 complexity score,routing 就很 straightforward。

The routing decision

加入下面这个 function,根据 score 进行 route:

def route_by_complexity(prompt):
    """Pick a model based on prompt complexity"""
    complexity = calculate_complexity(prompt)
   
    if complexity < 0.3:
        # Simple question - use the smallest, fastest model
        return {
            'model_url': os.getenv('NANO_MODEL_URL'),
            'model_name': os.getenv('NANO_MODEL'),
            'reason': 'simple query'
        }
    elif complexity < 0.7:
        # Medium complexity - use mid-size model
        return {
            'model_url': os.getenv('MICRO_MODEL_URL'),
            'model_name': os.getenv('MICRO_MODEL'),
            'reason': 'moderate complexity'
        }
    else:
        # Complex question - use the big model
        return {
            'model_url': os.getenv('LARGE_MODEL_URL'),
            'model_name': os.getenv('LARGE_MODEL'),
            'reason': 'high complexity'
        }

Thresholds(0.3 和 0.7)是 tunable 的。可以先使用这些 default values,然后根据实际 traffic patterns 调整。如果有太多 queries 被 route 到 large model,就提高 thresholds。如果 medium queries 的质量下降,就降低 thresholds。

Real-world results

当你部署 complexity-based routing 后,真实 traffic 中通常会出现以下情况:

  • 大约 60–70% 的 requests 会进入 small model
  • 20–30% 使用 medium model
  • 只有 5–10% 使用 large model

Small model 几乎不花钱,并且约 100 milliseconds 就能响应。Large model 可能每 token 成本高 10 倍,并且大约需要 850 milliseconds。也就是说,你会在大多数 requests 上同时节省成本和提升速度。

实践中,与所有事情都使用 large model 相比,这类 routing 可以把 AI inference costs 降低 80–90%。具体 savings 取决于你的 traffic distribution,但即使按保守估算,也能在 simple queries 没有 quality loss 的情况下,减少 50–60% 成本。

把 requests route 到不同 models 很有用,但现在我们构建一个更有意思的东西:一个由多个 AI agents 协同完成 task 的 system。

Building a multi-agent research assistant

本节中,我们会构建一个 research assistant,它能真正 search the web、analyze results,并写出 coherent reports。这个 multi-agent research assistant 由四个 specialized services 组成:coordinator、searcher、analyzer 和 writer。我们会先理解为什么将 system 拆成多个 agents,相比 monolithic approach,能带来 scalability、cost efficiency 和 easier debugging。然后定义整体 architecture,其中 agents 通过 Redis 和 HTTP APIs 通信,并使用 Docker Compose 实现完整 project structure。每个 agent 都作为独立 Flask service 构建,并承担自己的 responsibility:coordinator 规划 research queries;searcher 使用 Firecrawl API retrieve web data;analyzer 从 results 中提取 structured insights;writer 生成 polished research report。我们会为每个 role 配置不同 models,用 Dockerfiles containerize agents,并用 Compose orchestrate 一切。最后,我们会测试完整 pipeline,并 trace end-to-end execution flow,理解 agents 如何协作产生 final answer。

我们先从一个简单问题开始。

Why multiple agents?

你可能会问:一个 model 难道不能做完所有事情吗?当然,从技术上讲可以。但把工作拆分给 specialists,在 scale 上会显露出真正优势:

你可以独立 scale 每一部分。如果 searching 成为 bottleneck,就运行三个 searcher instances。Monolithic system 做不到这一点——即使只有一部分慢,你也必须 scale 整个 system。

每个 agent 都为自己的 job 使用正确 model。Coordinator 需要强 reasoning 来规划 research strategies,所以它使用 big model。Searcher 只是格式化 queries,所以 small model 就足够。Analyzer 和 writer 需要 good synthesis,所以使用 medium models。你不会为那些不需要 frontier-model tokens 的 tasks 付费。

Debug 和 improvement 也更容易。当 search results 很差时,你准确知道该查哪里——searcher agent。而如果所有事情都在一个 big agent 中完成,你很难弄清楚到底是 monolithic prompt 的哪一部分出了问题。

现在我们已经理解了使用 multiple agents 的动机,接下来看看这些 pieces 在 system level 是如何组合在一起的。

The architecture

Research assistant 使用四个 specialized agents,它们通过 Redis 和 HTTP APIs 通信。Figure 8.3 展示了这些组件如何组合在一起。

image.png

Figure 8.3:Multi-agent research assistant architecture

从图中可以看到,coordinator 负责规划 research 并委派给 specialists,而 Redis 为 agents 提供 shared state。

当 user 提出一个 research question 时,flow 如下:

  1. Coordinator 接收 question,并规划应该 search 什么。
  2. Searcher 通过 Firecrawl API 执行 web searches。
  3. Analyzer 从 search results 中提取 key insights。
  4. Writer 将 insights synthesizes 成 coherent report。

每一步都会把 results 存入 Redis,因此下一步可以从上一阶段停止的位置继续。这种 loose coupling 意味着 agents 不需要 direct know each other——它们只是 read from 和 write to shared state。

明确 architecture 后,下一步是组织 project,使每个 agent 都位于自己清晰定义的空间中。

Project structure

为每个 agent 创建一个 directory:

mkdir -p research-assistant/{coordinator,searcher,analyzer,writer}
cd research-assistant

你会在 root level 放一个 docker-compose.yaml,并在每个 agent 自己的 subdirectory 中放一个 separate Python app。

Directory structure 准备好后,就可以用 Docker Compose 把所有内容连接起来,定义 services 如何通信、使用哪些 models。

The Docker Compose setup

创建包含所有 services 的 docker-compose.yaml

The coordinator service

Coordinator 是 entry point——它接收来自外部的 requests,并把工作 delegate 给 specialists:

services:
  coordinator:
    build: ./coordinator
    ports:
      - "8080:8080"
    environment:
      - SEARCHER_URL=http://searcher:8081
      - ANALYZER_URL=http://analyzer:8082
      - WRITER_URL=http://writer:8083
      - REDIS_URL=redis://redis:6379
    depends_on:
      - searcher
      - analyzer
      - writer
      - redis
    models:
      coordinator-model:
        endpoint_var: MODEL_URL
        model_var: MODEL_NAME

它使用 ports 将 8080 expose 到你的 host machine。Environment variables 告诉它在哪里找到每个 specialist——Docker Compose 的 internal DNS 会自动解析像 searcher 这样的 service names。它使用 qwen3,因为规划 research strategies 需要 strong reasoning。

The searcher service

Searcher 通过 Firecrawl API fetch web results:

  searcher:
    build: ./searcher
    expose:
      - "8081"
    environment:
      - FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY}
      - REDIS_URL=redis://redis:6379
    depends_on:
      - redis
    models:
      searcher-model:
        endpoint_var: MODEL_URL
        model_var: MODEL_NAME

它使用 expose 而不是 ports——这样它可以在 Compose network 内访问,但不能从你的 host machine 直接访问。它是唯一带有 FIRECRAWL_API_KEY 的 service,因为只有它会调用 external API。它使用 granite-4.0-nano,因为 formatting search queries 并不需要 deep reasoning。

The analyzer service

Analyzer 从 Redis 读取 raw search results,并提取 structured insights:

  analyzer:
    build: ./analyzer
    expose:
      - "8082"
    environment:
      - REDIS_URL=redis://redis:6379
    depends_on:
      - redis
    models:
      analyzer-model:
        endpoint_var: MODEL_URL
        model_var: MODEL_NAME

它使用 granite-4.0-micro,而不是 nano,因为从 web content 中提取 meaningful insights 需要 good synthesis capabilities。

The writer service

Writer 从 Redis 读取 insights,并生成 final report:

  writer:
    build: ./writer
    expose:
      - "8083"
    environment:
      - REDIS_URL=redis://redis:6379
    depends_on:
      - redis
    models:
      writer-model:
        endpoint_var: MODEL_URL
        model_var: MODEL_NAME

它与 analyzer 共享 granite-4.0-micro——report writing 需要 fluent prose generation,但不需要 frontier-model reasoning。

Redis

所有四个 agents 共享一个 Redis instance,用于 coordination 和 state:

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s

--appendonly yes flag 启用 persistence,使 shared state 在 container restarts 后仍然保留。healthcheck 确保 dependent services 会等待 Redis 真正 ready,而不是只等待它刚启动。

Model assignments

models section 是 cost optimization 发生的地方——每个 agent 都获得一个与其 task 匹配的 model:

models:
  coordinator-model:
    model: ai/qwen3
    context_size: 8192
  searcher-model:
    model: ai/granite-4.0-nano
    context_size: 2048
  analyzer-model:
    model: ai/granite-4.0-micro
    context_size: 4096
  writer-model:
    model: ai/granite-4.0-micro
    context_size: 4096

volumes:
  redis-data:

The coordinator agent

Coordinator 是整个 operation 的 brains。它接收一个 question,决定如何 research,并 orchestrate 其他 agents。创建 coordinator/app.py,包含 Flask setup 和 environment variables:

from flask import Flask, request, jsonify
import requests
import redis
import os
import uuid
import json

app = Flask(__name__)

获取 environment variables:

SEARCHER_URL = os.getenv('SEARCHER_URL')
ANALYZER_URL = os.getenv('ANALYZER_URL')
WRITER_URL = os.getenv('WRITER_URL')
REDIS_URL = os.getenv('REDIS_URL')
MODEL_URL = os.getenv('MODEL_URL')
MODEL_NAME = os.getenv('MODEL_NAME')

连接到 Redis:

redis_client = redis.from_url(REDIS_URL)

Main endpoint 接收一个 research question,并 orchestrate 整个 pipeline:

@app.route('/api/research', methods=['POST'])
def research():
    data = request.json
    question = data.get('question')

为这个 research task 生成一个 unique ID:

    research_id = str(uuid.uuid4())

把 question 存入 Redis:

    redis_client.hset(research_id, "question", question)

Step 1:Plan the research(我们应该 search 什么?)

    queries = plan_research(question)
    redis_client.hset(research_id, "queries", json.dumps(queries))

Step 2:Search the web

    search_results = search_web(queries, research_id)

Step 3:Analyze the results

    insights = analyze_results(research_id)

Step 4:Write the final report

    report = write_report(research_id)

    return jsonify({
        "question": question,
        "queries": queries,
        "insights": insights,
        "report": report
    })

research_id 非常关键——它是把所有 steps 在 Redis 中串联起来的 key。每个 agent 都会 read from 和 write to 以这个 ID 为 prefix 的 keys,因此多个 research tasks 可以同时运行,并且互不干扰。

现在添加 planning function,使用 coordinator 的 model 来生成 search queries:

def plan_research(question):
    """Ask the coordinator model: what should we search for?"""
    messages = f"""Given this research question: "{question}"
    Generate 3 specific search queries that will help answer it.
    Return ONLY a JSON array like: ["query 1", "query 2", "query 3"]"""
    response = requests.post(
        f"{MODEL_URL}/v1/chat/completions",
        json={
            "model": MODEL_NAME,
            "prompt": prompt,
            "max_tokens": 200,
            "temperature": 0.7
        },
        timeout=30
    )
    if response.status_code == 200:
        result = response.json()['choices'][0]['text'].strip()
        try:
            queries = json.loads(result)
            return queries
        except:
            # If JSON parsing fails, use the original question
            return [question]
    return [question]

Fallback 到 [question] 非常重要。如果 model 返回 malformed JSON,或者 API failed,我们仍然可以用 original question 作为 search query 继续执行,而不是完全失败。

添加 delegate 给其他 agents 的 functions:

def search_web(queries, research_id):
    """Tell the searcher agent to find information"""
    response = requests.post(
        f"{SEARCHER_URL}/api/search",
        json={
            "queries": queries,
            "research_id": research_id
        },
        timeout=60
    )
    if response.status_code == 200:
        return response.json()
    return []

def analyze_results(research_id):
    """Tell the analyzer to extract insights"""
    response = requests.post(
        f"{ANALYZER_URL}/api/analyze",
        json={"research_id": research_id},
        timeout=60
    )
    if response.status_code == 200:
        return response.json().get('insights', [])
    return []

def write_report(research_id):
    """Tell the writer to create the final report"""
    response = requests.post(
        f"{WRITER_URL}/api/write",
        json={"research_id": research_id},
        timeout=60
    )
    if response.status_code == 200:
        return response.json().get('report', '')
    return ''

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080)

注意,coordinator 只负责 orchestrate——它并不实际执行 searching、analyzing 或 writing。它会 delegate 给 specialists,并协调整个 flow。

现在 coordinator 已经能够 plan research tasks,我们还需要一个 specialist,负责真正出门从 web 上 retrieve information。

The searcher agent

Searcher 的工作很简单:接收 search queries,并使用 Firecrawl 真正 search the web。创建 searcher/app.py

from flask import Flask, request, jsonify
import requests
import redis
import os
import json
import logging

app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

FIRECRAWL_API_KEY = os.getenv('FIRECRAWL_API_KEY')
REDIS_URL = os.getenv('REDIS_URL')
redis_client = redis.from_url(REDIS_URL)

添加调用 Firecrawl API 的 function:

def search_with_firecrawl(query):
    """Use Firecrawl API to search the web"""
    try:
        response = requests.post(
            "https://api.firecrawl.dev/v1/search",
            headers={
                "Authorization": f"Bearer {FIRECRAWL_API_KEY}",
                "Content-Type": "application/json"
            },
            json={
                "query": query,
                "limit": 5
            },
            timeout=30
        )
        if response.status_code == 200:
            data = response.json()
            logger.info(f"Found {len(data.get('data', []))} results for: {query}")
            return data
        else:
            logger.error(f"Search failed: {response.status_code}")
            return {"data": []}
    except Exception as e:
        logger.error(f"Search error: {str(e)}")
        return {"data": []}

limit: 5 会让 search results 保持 manageable。更多 results 意味着 analyzer 拥有更多 context,但也意味着要处理更多 tokens。每个 query 5 条 results 是一个很好的 balance。

现在添加 search endpoint:

@app.route('/api/search', methods=['POST'])
def search():
    data = request.json
    queries = data.get('queries', [])
    research_id = data.get('research_id')
    all_results = []
    for query in queries:
        logger.info(f"Searching: {query}")
        results = search_with_firecrawl(query)
        all_results.append({
            "query": query,
            "results": results.get('data', [])
        })
    # Store results in Redis for the analyzer
    if research_id:
        redis_client.hset(research_id, "search_results", json.dumps(all_results))
    return jsonify({
        "searches": all_results,
        "total_results": sum(len(r['results']) for r in all_results)
    })

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8081)

Searcher 不需要 big model——它只是 calling an API 并 storing results。这就是为什么我们给它 granite-nano:smallest and fastest option。这里用 larger model 只会浪费 money,不会带来 quality benefit。

收集 raw search results 之后,下一步是将这些 information 转换为 structured insights。这就是 analyzer 的职责。

The analyzer agent

Analyzer 从 Redis 中 pull search results,并提取 key insights。创建 analyzer/app.py

from flask import Flask, request, jsonify
import requests
import redis
import os
import json

app = Flask(__name__)
REDIS_URL = os.getenv('REDIS_URL')
MODEL_URL = os.getenv('MODEL_URL')
MODEL_NAME = os.getenv('MODEL_NAME')
redis_client = redis.from_url(REDIS_URL)

@app.route('/api/analyze', methods=['POST'])
def analyze():
    data = request.json
    research_id = data.get('research_id')

从 Redis 获取 search results:

    search_results_json = redis_client.hget(research_id, "search_results")
    if not search_results_json:
        return jsonify({"insights": []})
    search_results = json.loads(search_results_json)

从 search results 构建 context:

    context = ""
    for search in search_results:
        for result in search.get('results', [])[:3]:
            title = result.get('title', 'No title')
            content = result.get('content', result.get('description', ''))[:300]
            context += f"{title}\n{content}\n\n"

请求 model 提取 insights:

    question = redis_client.hget(research_id, "question")
    prompt = f"""Question: {question}
    Search results:
    {context}
    Extract 3-5 key insights that answer the question.
    Return ONLY a JSON array: ["insight 1", "insight 2", ...]"""
    response = requests.post(
        f"{MODEL_URL}/v1/chat/completions",
        json={
            "model": MODEL_NAME,
            "prompt": prompt,
            "max_tokens": 500,
            "temperature": 0.7
        },
        timeout=30
    )
    insights = []
    if response.status_code == 200:
        result = response.json()['choices'][0]['text'].strip()
        try:
            insights = json.loads(result)
        except:
            insights = ["Unable to extract insights"]

把 insights 存入 Redis:

    redis_client.hset(research_id, "insights", json.dumps(insights))
    return jsonify({"insights": insights})

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8082)

Analyzer 使用 granite-micro——它比 nano 大,但比 qwen3 小。它擅长 synthesis 和 summarization,而这正是 extracting insights 所需的能力。对 content 做 [:300] truncation 可以让 prompt size 保持 manageable,同时仍然捕获每个 result 的 key information。

提取 insights 之后,我们需要把它们转成 readable 和 coherent 的内容,这就是 writer 的职责。

The writer agent

最后,writer 接收 insights,并创建 coherent report。创建 writer/app.py

from flask import Flask, request, jsonify
import requests
import redis
import os
import json

app = Flask(__name__)
REDIS_URL = os.getenv('REDIS_URL')
MODEL_URL = os.getenv('MODEL_URL')
MODEL_NAME = os.getenv('MODEL_NAME')
redis_client = redis.from_url(REDIS_URL)

@app.route('/api/write', methods=['POST'])
def write():
    data = request.json
    research_id = data.get('research_id')

从 Redis 获取 question 和 insights:

    question = redis_client.hget(research_id, "question")
    insights_json = redis_client.hget(research_id, "insights")
    if not insights_json:
        return jsonify({"report": "No insights found"})
    insights = json.loads(insights_json)
    insights_text = "\n".join([f"- {insight}" for insight in insights])

生成 report:

    prompt = f"""Question: {question}
    Key findings:
    {insights_text}
    Write a clear, well-structured research report (2-3 paragraphs) that
    answers the question using these findings."""
    response = requests.post(
        f"{MODEL_URL}/completions",
        json={
            "model": MODEL_NAME,
            "prompt": prompt,
            "max_tokens": 800,
            "temperature": 0.7
        },
        timeout=30
    )
    report = ""
    if response.status_code == 200:
        report = response.json()['choices'][0]['text'].strip()

存储 final report:

    redis_client.hset(research_id, "report", report)
    return jsonify({"report": report})

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8083)

Writer 也使用 granite-micro。它需要从 bullet points 生成 fluent、coherent prose,这需要 decent language capability,但不需要 frontier-model reasoning。

所有四个 agents 都实现之后,我们还需要一种方式将它们 containerize,使它们可以在 Compose environment 中一致运行。

The Dockerfiles

每个 agent 都需要一个 Dockerfile。它们都是相同的,因为 dependencies 一样。在四个 agent directories 中分别创建这个 file(coordinator/Dockerfilesearcher/Dockerfileanalyzer/Dockerfilewriter/Dockerfile):

FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir flask requests redis
COPY app.py .
CMD ["python", "app.py"]

现在所有东西都已经 build 和 containerized,是时候启动 system,看看完整 multi-agent workflow 如何运行了。

Testing the system

首先,把 Firecrawl API key 设置为 environment variable:

export FIRECRAWL_API_KEY="your_key_here"

然后启动所有 services:

docker compose up --build

第一次运行会下载 models,需要几分钟。观察 logs,直到你看到所有四个 agents 都报告 ready。

一切运行后,测试 research assistant:

curl -X POST http://localhost:8080/api/research \
  -H "Content-Type: application/json" \
  -d '{"question": "What are the latest Docker AI developments?"}'

你会得到一个 structured response,其中包含使用过的 search queries、提取出的 insights,以及 final report。整个过程大约需要 8 到 10 秒,具体取决于 model loading 和 web search latency。

系统 end-to-end 运行之后,我们暂停一下,逐步 trace execution,以充分理解 agents 如何协作生成 final result。

What just happened?

让我们 trace execution,理解 flow:

  1. Coordinator 接收你的 question,并请求 qwen3 生成三个 search queries。
  2. Searcher 接收这些 queries,并使用 Firecrawl 真正 search the web,将 raw results 存入 Redis。
  3. Analyzer 从 Redis 读取 search results,并使用 granite-micro 提取 key insights。
  4. Writer 从 Redis 读取 insights,并使用 granite-micro 将它们 synthesis 成 coherent report。
  5. 每一步都把 results 存入 Redis,因此下一步可以访问它们。

这就是一个真正的 multi-agent system。每个 agent 都有 specific job,使用适合该 job 的 right model,并且可以独立 scale。Coordinator 不关心有多少 searcher instances 正在运行——它只是调用 searcher service,剩下的由 Docker 处理。

现在我们已经构建并测试了一个 fully working multi-agent research assistant,下一步自然是探索如何 scale 它。在下一节中,我们会看如何扩展 system 来处理更高 workload、提高 resilience,并利用我们设计的 modular architecture。

Scaling multi-agent systems for production traffic

Research assistant 对 single requests 运行得很好,但如果需要处理更多 traffic,会发生什么?你当然可以给整个 system 增加更多 CPU,但有更聪明的方式:根据实际 bottlenecks 来 scale individual agents。

本节中,你将学习如何在不修改 application code 的情况下 scale multi-agent research assistant,以处理更高 traffic。你会先理解为什么 scale individual agents 比 scale 整个 system 更高效。然后探索一个常见陷阱——port binding problem,并通过将 internal services 的 ports 替换为 expose 来修复它,使多个 container instances 可以 simultaneous 运行。完成这个调整之后,你会使用 Docker Compose 的 --scale flag 来 scale specific agents,验证 load balancing 是否通过 Docker 内置 DNS round-robin mechanism 正常工作,并用 parallel requests 测试 concurrency。最后,你会学习如何根据 real bottlenecks 决定 scale 哪些 agents,并查看 strategic scaling 带来的真实 performance improvements。

不过在开始 scale containers 之前,有一个重要 networking detail 需要先处理;否则我们的 scaling attempt 会立刻失败。

The port problem

这是一件很多人在第一次尝试 scale 时都会踩坑的事。看看这个 service definition:

searcher:
  build: ./searcher
  ports:
    - "8081:8081"

这一行 ports 的意思是:“把我机器上的 port 8081 绑定到 container 中的 port 8081。” 对一个 container 来说没问题,但如果你想运行三个 searchers 呢?

docker compose up --scale searcher=3

你会得到一个 error。为什么?因为在你的 host machine 上,同一时间只有一个 process 可以 bind 到 port 8081。第二个和第三个 searcher instances 无法启动,因为 port 已经被占用了。

既然我们已经理解 port binding 为什么会破坏 scaling,接下来看看一个简单 configuration change,如何使 horizontal scaling 成为可能。

The fix: use expose instead

解决方案是对 internal services 使用 expose 而不是 ports。修改你的 service definition:

searcher:
  build: ./searcher
  expose:
    - "8081"  # Only visible inside Docker network

expose directive 告诉 Docker:“这个 container 会监听 port 8081”,但它不会试图在你的 host machine 上 bind 这个 port。其他 containers 可以通过 http://searcher:8081 访问它,而 Docker 会在多个 instances 之间自动进行 load-balancing。

关键 insight 是:只有 external clients 需要 direct access 的 services 才需要 ports。Coordinator 是 entry point——users 从 Docker 外部调用它——所以它保留 ports: "8080:8080"。但 searcher、analyzer 和 writer 只与其他 containers 通信,因此它们使用 expose

我们在 research assistant 中已经这样配置了。回头看 docker-compose.yaml——只有 coordinator 使用 ports,其他都使用 expose。这意味着 scaling 开箱即用。

services:
  coordinator:
    build: ./coordinator
    ports:
      - "8080:8080"  # Keep this - you need external access
    environment:
      - SEARCHER_URL=http://searcher:8081
      # ... other config
  searcher:
    build: ./searcher
    expose:
      - "8081"  # Changed from ports
      # ... rest of config
  analyzer:
    build: ./analyzer
    expose:
      - "8082"  # Changed from ports
  writer:
    build: ./writer
    expose:
      - "8083"  # Changed from ports

Networking issue 解决后,我们终于可以开始 scale individual agents,并观察 Docker 如何处理多个 instances。

Scaling up

现在你可以独立 scale individual agents。

运行 3 个 searcher instances:

docker compose up -d --scale searcher=3

同时 scale searchers 和 analyzers:

docker compose up -d --scale searcher=3 --scale analyzer=2

进一步扩大:

docker compose up -d --scale searcher=3 --scale analyzer=2 --scale writer=2

检查正在运行的内容:

docker compose ps

你会看到类似:

NAME                              STATUS
research-assistant-coordinator-1  Up
research-assistant-searcher-1     Up
research-assistant-searcher-2     Up
research-assistant-searcher-3     Up
research-assistant-analyzer-1     Up
research-assistant-analyzer-2     Up
research-assistant-writer-1       Up
research-assistant-writer-2       Up
research-assistant-redis-1        Up

多个 instances 运行后,自然会出现一个问题:traffic 到底如何在它们之间分发?

How load balancing works

Docker 通过 DNS 自动处理 load balancing。当 coordinator 调用 http://searcher:8081 时,Docker 的 DNS 会按 round-robin order 返回所有三个 searcher IP addresses:

  • First request → searcher-1
  • Second request → searcher-2
  • Third request → searcher-3
  • Fourth request → searcher-1(回到开始)

你不需要配置任何东西。Docker 的 embedded DNS server 会 track 每个 service 下哪些 containers 正在运行,并自动 distribute requests。

不过不要只是相信 Docker 的 load balancing,我们自己验证一下:同时发送多个 requests。

Testing concurrent requests

验证 load distribution 是否真的工作。发送五个 simultaneous requests:

for i in {1..5}; do
  curl -X POST http://localhost:8080/api/research \
    -H "Content-Type: application/json" \
    -d "{"question": "Research topic $i"}" &
done
wait

& 会让每个 curl 在 background 中运行,而 wait 会暂停,直到所有请求完成。现在检查 searcher logs:

docker compose logs searcher-1 searcher-2 searcher-3 | grep "Searching:"

你会看到工作被分散到所有三个 searchers 上。每个实例处理的 queries 数量大致相同,这证明 load balancing 正常工作。

现在 scaling 已经在 technical 层面可行,更 strategic 的问题是:哪些 agents 实际需要 scale?

When to scale what

你不需要让所有东西都等比例 scale。应该根据实际 bottleneck 来 scale:

Searches are slow? Scale the searcher. Web searches 最慢,因为它们依赖 external API calls。多个 searchers 可以让 searches parallel 运行。

Analysis is backed up? Scale the analyzer. 如果你得到大量 search results,analysis 可能成为 bottleneck。更多 analyzers 可以更快 process results。

Report generation is laggy? Scale the writer. 不过这很少是 bottleneck,因为 writing 通常比 searching 或 analyzing 更快。

Lots of concurrent users? Scale everything proportionally. 当 traffic 很高时,按比例 scale 所有 agents 以匹配需求。

实践中,你大概率会运行类似配置:

  • 1 coordinator(很少成为 bottleneck——它只是 orchestrate)
  • 3 searchers(searching 因 network latency 而很慢)
  • 2 analyzers(moderate load——比 writing 慢,比 searching 快)
  • 1–2 writers(通常一个 writer 就够快)

最后,我们看一下:如果你 intelligent scaling,而不是简单增加 vertical resources,大概可以得到什么样的 performance improvement。

Real-world performance

当你从每个 agent 1 个实例,scale 到 3 个 searchers、2 个 analyzers、2 个 writers 后,通常会发生:

Before scaling:

  • Concurrent requests:一次 1 个(sequential processing)
  • Response time:每个 request 约 8 秒
  • Throughput:大约每分钟 7 个 requests

After scaling:

  • Concurrent requests:一次 6–8 个
  • Response time:仍然每个约 8 秒(但 6–8 个同时发生)
  • Throughput:大约每分钟 40–50 个 requests

这意味着 throughput 提升 6–7 倍,而且没有任何 code changes。你只是运行了更多 instances,并让 Docker 处理 coordination。

Summary

本章中,你构建了 multi-model 和 multi-agent AI systems——这些 architectures 会把 work route 到 specialized models,并 coordinate 多个 agents 来完成 complex tasks。它们不是用一个 model 处理所有事情的 monolithic systems;它们是 intelligent systems,会基于 capability 和 complexity,把每个 task 匹配给正确 model。

两种 routing strategies 解决不同问题。Task-based routing 将 work 发送给 specialized models——code questions 发给 code models,vision tasks 发给 vision models,text analysis 发给 text models。Complexity-based routing 基于 difficulty 选择 model size——simple queries 发给 small、fast models,complex reasoning 发给 large、powerful models。你使用 Docker Model Runner 实现了这两种 approaches,并且相较于所有 tasks 都使用 single frontier model,实现了 80–90% 的成本降低。

Multi-agent research assistant 展示了 hierarchical coordination。Coordinator agent(qwen3)规划 research strategies 并 delegate 给 specialist agents。Searcher agent(granite-nano)通过 Firecrawl API 执行 web searches。Analyzer agent(granite-micro)从 search results 中提取 insights。Writer agent(granite-micro)将 findings synthesis 成 coherent reports。每个 agent 都为自己的 specific responsibility 使用 right-sized model,并由 Redis 提供 shared state 进行 coordination。

Horizontal scaling 支持 production throughput。移除 explicit port mappings 并改用 expose,使 Docker Compose 能够独立 scale individual agents。三个 searcher instances 处理 parallel web searches。两个 analyzer instances concurrent process results。Docker 基于 DNS 的 automatic load balancing 会在 scaled replicas 之间 distribute requests。你可以 scale bottleneck components,而不需要修改 code,也不需要除了 --scale flag 之外的额外 configuration。

为每个 job 选择 right model,同时驱动 quality 和 efficiency。Small models(granite-nano)以 100 ms latency 和 minimal cost 处理 straightforward tasks。Medium models(granite-micro)在 capability 和 speed 之间取得平衡,适合大多数 real-world work。Large models(qwen3)在 simpler models 无法交付 adequate results 时,处理 complex reasoning。Intelligent routing 让你只在必要时才使用 expensive models,同时维持质量。

在第 9 章中,你将探索 Docker 官方用于 multi-agent systems 的 frameworks——LangGraph 用于 graph-based workflows,CrewAI 用于 role-based coordination——并理解什么时候应该构建 custom solutions,什么时候应该利用 existing frameworks。