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 服务器虽然简单,但包含了网络编程的核心要素:
- Socket 通信:建立与客户端的连接
- 数据收发:读取请求,发送响应
- 协议基础:理解 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;
}
关键点解析:
-
io_context:Boost.Asio 的核心类,负责管理 I/O 操作。在同步模式下,它主要提供执行上下文。 -
tcp::acceptor:专门用于接受新连接的类。构造时即绑定端口并开始监听。 -
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";
}
}
关键技术点:
-
asio::buffer:将原始数组包装成 Asio 需要的缓冲区类型,同时记录缓冲区大小防止溢出。 -
请求解析策略:Step 0 只做最简单的解析——提取路径。完整的 HTTP 解析器需要考虑:
- 多行请求头
- 请求体长度(Content-Length)
- 分块传输(Chunked encoding)
-
持久连接(Keep-Alive)
-
阻塞读的特性:
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 响应的关键要素:
-
Content-Length:必须准确,否则客户端不知道什么时候接收完成。如果长度不对,浏览器会显示异常或一直等待。 -
Connection: close:告诉客户端发送完成后关闭连接。HTTP/1.1 默认是持久连接(Keep-Alive),但 Echo 服务器简单起见,用一次就关。 -
\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 服务器有一个致命缺陷——只能同时服务一个客户端:
对比现实场景:
| 场景 | 阻塞式 I/O | 理想情况 |
|---|---|---|
| 餐厅 | 一个服务员,一次服务一桌 | 一个服务员,多桌同时服务 |
| 客服 | 一个客服,一次接一个电话 | 多个客服同时接听 |
| 网站 | 用户排队等待响应 | 多用户同时访问 |
这就是 Step 1 要解决的问题——异步 I/O,让服务器能同时处理多个连接。
五、本章小结¶
核心收获:
-
Socket 编程基础:理解 TCP 连接建立、数据传输、连接关闭的完整生命周期
-
HTTP 协议格式:掌握请求报文和响应报文的结构,理解状态行、头部、空行、正文的分层组织
-
阻塞 I/O 模型:理解同步编程的特点——简单直观但无法并发
-
Boost.Asio 入门:学会使用 io_context、acceptor、socket 进行网络编程
关键代码模式:
六、引出的问题¶
Echo 服务器虽然让 Agent 有了"耳朵",但存在明显问题:
6.1 并发问题¶
问题: 如果处理用户 A 的请求需要 5 秒(比如调用外部 API),用户 B 和 C 必须等待。
这引出了下一个核心问题:如何让服务器同时处理多个连接?
6.2 架构问题¶
当前的代码把"接受连接"和"处理请求"混在一起:
问题: 随着功能增加,代码会越来越复杂。
这引出了另一个问题:如何设计清晰的架构分离网络层和业务层?
下一章预告(Step 1):
我们将解决并发问题,学习异步 I/O 编程模型: - 理解事件循环和回调机制 - 使用 Boost.Asio 的异步 API - 设计 Session 类分离连接管理 - 让服务器能同时服务成百上千个连接
准备好了吗?让我们进入异步编程的世界。