跳转至

Step 0: Echo 服务器 —— Agent 的耳朵

目标:构建一个能接收和响应消息的基础服务器,这是 Agent 感知世界的起点

难度:⭐ | 代码量:约 89 行 | 预计学习时间:1-2 小时


一、为什么从 Echo 服务器开始?

1.1 Agent 的感知层

一个 AI Agent 要能与外界交互,首先需要感知能力——能够接收输入、发送响应。

┌─────────────────────────────────────────────────────────┐
│                      AI Agent                           │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  ┌──────────────┐     ┌──────────────┐                │
│  │   感知层     │────▶│   认知层     │                │
│  │  (网络 I/O)  │     │  (LLM/推理)  │                │
│  └──────────────┘     └──────────────┘                │
│         ▲                      │                       │
│         │                      ▼                       │
│  ┌──────────────┐     ┌──────────────┐                │
│  │   用户输入   │◀────│   执行响应   │                │
│  └──────────────┘     └──────────────┘                │
│                                                         │
└─────────────────────────────────────────────────────────┘

Step 0 就是构建这个感知层的基础——让 Agent 能"听见"(接收请求)和"回应"(发送响应)。

1.2 Echo 服务器的价值

Echo 服务器虽然简单,但包含了网络编程的核心要素:

  1. Socket 通信:建立与客户端的连接
  2. 数据收发:读取请求,发送响应
  3. 协议基础:理解 HTTP 报文格式

这就像是教一个新生儿先学会"听见声音"和"发出声音",然后才能学习理解语言和表达思想。


二、核心概念详解

2.1 Socket:网络通信的端点

什么是 Socket?

Socket(套接字)是操作系统提供的网络通信抽象。你可以把它想象成电话系统的分机号码

现实世界:                    计算机世界:
┌─────────────┐              ┌─────────────────────┐
│ 电话号码    │              │   IP 地址 + 端口    │
│ 138xxxx8888 │  ────────▶   │  192.168.1.1:8080   │
└─────────────┘              └─────────────────────┘
         │                            │
         ▼                            ▼
┌─────────────┐              ┌─────────────────────┐
│ 接听电话    │              │   Socket 监听       │
│ 拨打电话    │              │   Socket 连接       │
└─────────────┘              └─────────────────────┘

Socket 的核心操作:

操作 函数 类比
创建 Socket socket() 安装电话机
绑定地址 bind() 申请电话号码
监听连接 listen() 开启来电提醒
接受连接 accept() 接听电话
发送数据 send() / write() 说话
接收数据 recv() / read() 听话
关闭连接 close() 挂电话

2.2 TCP 三次握手

在数据传输之前,客户端和服务器需要建立连接,这个过程叫三次握手

客户端                                        服务器
   │                                            │
   │────────── SYN (我能连你吗?) ───────────────▶│
   │                                            │
   │◀───────── SYN-ACK (能,你也能连我吗?) ─────│
   │                                            │
   │────────── ACK (能!) ──────────────────────▶│
   │                                            │
   │◀══════════════════════════════════════════▶│
   │           连接建立,开始数据传输            │

为什么需要三次? - 第一次:客户端证明自己能发数据 - 第二次:服务器证明自己能收数据、能发数据 - 第三次:客户端证明自己能收数据

只有三次握手完成,双方才能确认彼此的网络通路是正常的。

2.3 HTTP 协议基础

Echo 服务器需要理解基本的 HTTP 请求格式:

请求行:方法 路径 协议版本
        ↓    ↓     ↓
       GET /hello HTTP/1.1

请求头:键值对形式
       Host: localhost:8080
       User-Agent: curl/7.68.0
       Accept: */*

空行:
       \r\n

请求体(可选):
       (GET 请求通常没有)

响应格式:

状态行:协议版本 状态码 状态描述
        ↓          ↓       ↓
       HTTP/1.1   200    OK

响应头:
       Content-Type: text/plain
       Content-Length: 13

空行:
       \r\n

响应体:
       Hello, World!

2.4 阻塞式 I/O

Step 0 使用阻塞式(Blocking)I/O,意思是当程序执行读写操作时,会一直等待直到操作完成:

程序执行流程:

开始 ──▶ 创建 Socket ──▶ 绑定端口 ──▶ 开始监听
                                    等待客户端连接 ←────┐
                                            │          │
                                            ▼          │
                                    接受连接 ──▶ 读取请求
                                            处理请求(Echo)
                                            发送响应 ────┘

阻塞的特点: - 代码简单直观,符合直觉 - 一次只能处理一个连接 - 适合理解原理,不适合生产环境


三、核心代码实现

3.1 服务器初始化流程

#include <boost/asio.hpp>
#include <iostream>

namespace asio = boost::asio;
using tcp = asio::ip::tcp;

int main() {
    try {
        // 1. 创建 I/O 执行上下文(类似事件循环,但同步模式下是阻塞调度)
        asio::io_context io;

        // 2. 创建 Acceptor,监听 8080 端口
        // 参数1:io 上下文
        // 参数2:监听地址(v4 表示 IPv4,0.0.0.0 表示所有网卡)
        // 参数3:端口号
        tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080));

        std::cout << "Server listening on port 8080...\n";

        // 3. 进入主循环,持续接受连接
        while (true) {
            // 创建一个新的 socket 用于与客户端通信
            tcp::socket socket(io);

            // 阻塞等待客户端连接
            // 当有客户端连接时,accept() 返回,socket 被绑定到该连接
            acceptor.accept(socket);

            // 处理这个连接...
            handle_connection(socket);
        }
    } catch (std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
    }

    return 0;
}

关键点解析:

  1. io_context:Boost.Asio 的核心类,负责管理 I/O 操作。在同步模式下,它主要提供执行上下文。

  2. tcp::acceptor:专门用于接受新连接的类。构造时即绑定端口并开始监听。

  3. accept():这是一个阻塞调用。如果没有客户端连接,程序会停在这里等待。

3.2 请求读取与解析

void handle_connection(tcp::socket& socket) {
    try {
        // 1. 读取 HTTP 请求
        // 使用一个缓冲区存储接收到的数据
        char buffer[1024] = {0};

        // read_some 读取数据,返回读取的字节数
        // 这也是阻塞调用,直到收到数据或连接关闭
        size_t bytes_read = socket.read_some(asio::buffer(buffer));

        std::string request(buffer, bytes_read);
        std::cout << "Received request:\n" << request << "\n";

        // 2. 简单解析:提取请求路径
        // 请求行格式:GET /path HTTP/1.1
        std::string path = "/";

        // 查找第一个空格(方法后面的空格)
        size_t first_space = request.find(' ');
        if (first_space != std::string::npos) {
            // 查找第二个空格(路径后面的空格)
            size_t second_space = request.find(' ', first_space + 1);
            if (second_space != std::string::npos) {
                // 提取中间的路径部分
                path = request.substr(first_space + 1, 
                                     second_space - first_space - 1);
            }
        }

        // 3. 生成响应
        send_response(socket, path);

    } catch (std::exception& e) {
        std::cerr << "Connection error: " << e.what() << "\n";
    }
}

关键技术点:

  1. asio::buffer:将原始数组包装成 Asio 需要的缓冲区类型,同时记录缓冲区大小防止溢出。

  2. 请求解析策略:Step 0 只做最简单的解析——提取路径。完整的 HTTP 解析器需要考虑:

  3. 多行请求头
  4. 请求体长度(Content-Length)
  5. 分块传输(Chunked encoding)
  6. 持久连接(Keep-Alive)

  7. 阻塞读的特性read_some 可能一次读不完所有数据(特别是请求体较大时),生产环境需要循环读取直到读完。

3.3 响应生成与发送

void send_response(tcp::socket& socket, const std::string& path) {
    // 1. 构造响应体
    // Echo 服务器:把请求的路径返回给客户端
    std::string body = "Echo: " + path + "\n";

    // 2. 构造 HTTP 响应报文
    std::string response = 
        "HTTP/1.1 200 OK\r\n"                    // 状态行
        "Content-Type: text/plain\r\n"           // 内容类型
        "Content-Length: " +                     // 内容长度(必须准确)
        std::to_string(body.length()) + "\r\n"
        "Connection: close\r\n"                  // 发送完关闭连接
        "\r\n"                                   // 空行分隔
        + body;                                  // 响应体

    // 3. 发送响应
    // write 函数将缓冲区数据全部发送(或直到出错)
    asio::write(socket, asio::buffer(response));

    // 4. 关闭 Socket
    // shutdown 优雅地关闭连接:通知对端不再发送数据
    socket.shutdown(tcp::socket::shutdown_both);

    // close 释放系统资源
    socket.close();
}

HTTP 响应的关键要素:

  1. Content-Length:必须准确,否则客户端不知道什么时候接收完成。如果长度不对,浏览器会显示异常或一直等待。

  2. Connection: close:告诉客户端发送完成后关闭连接。HTTP/1.1 默认是持久连接(Keep-Alive),但 Echo 服务器简单起见,用一次就关。

  3. \r\n:HTTP 协议规定使用 CRLF(回车+换行)作为行结束符,不是单纯的 \n


四、完整的 Agent 感知层

4.1 为什么这是 Agent 的"耳朵"?

Echo 服务器虽然只返回路径,但它建立了 Agent 与外界通信的基础通道:

当前 Step 0:
┌─────────┐     HTTP     ┌──────────────┐
│  用户   │──────────────▶│ Echo Server  │
│ (curl)  │◀──────────────│  (返回路径)  │
└─────────┘   响应        └──────────────┘

未来 Step 5+:
┌─────────┐     HTTP     ┌──────────────┐
│  用户   │──────────────▶│ Agent Server │
│ (curl)  │◀──────────────│              │
└─────────┘   AI 回复     │ ┌──────────┐ │
                          │ │  LLM     │ │
                          │ │  推理    │ │
                          │ └──────────┘ │
                          └──────────────┘

Echo 服务器提供的通用能力: - 网络接入层(以后不需要改) - HTTP 协议解析(以后增强) - 请求路由基础(以后扩展) - 响应封装(以后复用)

4.2 阻塞 I/O 的局限

Echo 服务器有一个致命缺陷——只能同时服务一个客户端

时间线:

Client A ──连接────▶ 接受 A ──处理 A ──响应 A ──关闭
Client B ──────────────┘ (等待 A 完成才能接受 B)

对比现实场景:

场景 阻塞式 I/O 理想情况
餐厅 一个服务员,一次服务一桌 一个服务员,多桌同时服务
客服 一个客服,一次接一个电话 多个客服同时接听
网站 用户排队等待响应 多用户同时访问

这就是 Step 1 要解决的问题——异步 I/O,让服务器能同时处理多个连接。


五、本章小结

核心收获:

  1. Socket 编程基础:理解 TCP 连接建立、数据传输、连接关闭的完整生命周期

  2. HTTP 协议格式:掌握请求报文和响应报文的结构,理解状态行、头部、空行、正文的分层组织

  3. 阻塞 I/O 模型:理解同步编程的特点——简单直观但无法并发

  4. Boost.Asio 入门:学会使用 io_context、acceptor、socket 进行网络编程

关键代码模式:

创建 io_context ──▶ 创建 acceptor 绑定端口 ──▶ 循环 accept
                                              创建 socket
                                              读取请求
                                              处理(Echo)
                                              发送响应
                                              关闭连接

六、引出的问题

Echo 服务器虽然让 Agent 有了"耳朵",但存在明显问题:

6.1 并发问题

用户 A 连接 ──▶ 正在处理...
用户 B 连接 ──▶ 阻塞等待... (无法同时服务)
用户 C 连接 ──▶ 阻塞等待...

问题: 如果处理用户 A 的请求需要 5 秒(比如调用外部 API),用户 B 和 C 必须等待。

这引出了下一个核心问题:如何让服务器同时处理多个连接?

6.2 架构问题

当前的代码把"接受连接"和"处理请求"混在一起:

while (true) {
    accept(socket);      // 网络层
    handle(socket);      // 业务层(混在一起)
}

问题: 随着功能增加,代码会越来越复杂。

这引出了另一个问题:如何设计清晰的架构分离网络层和业务层?


下一章预告(Step 1):

我们将解决并发问题,学习异步 I/O 编程模型: - 理解事件循环和回调机制 - 使用 Boost.Asio 的异步 API - 设计 Session 类分离连接管理 - 让服务器能同时服务成百上千个连接

准备好了吗?让我们进入异步编程的世界。