NO.2|数据结构设计|日志封装|DeepSeel接入封装|全量返回实现测试|SSE|流式响应实现测试

数据结构设计

虽然各个模型不同,但有⼀些公共的配置和描述信息,⽐如:

通过api调⽤模型时需要模型名称、温度值、最⼤tokens数、api key等;

在和模型聊天时,聊天信息需要管理;

每次开启和模型的新⼀轮对话,都是⼀次新的会话,将来可能需要实现会话管理。

这些数据在多个⽂件中都会⽤到,因此提前先将这些数据结构定义好,以⽅便后续使⽤

spdlog封装

C++中可以通过cout将信息打印到控制台,为什么还要封装⽇志库呢?

封装⽇志库有诸多优势:

  • ⽇志级别管理
    ⽇志库通常⽀持多种⽇志级别(如TRACE、DEBUG、INFO、WARN、ERROR、FATAL等)。开发者可以根据需要设置不同的⽇志级别,以便在开发、测试和⽣产环境中灵活控制⽇志输出。
    在开发阶段,可以将⽇志级别设置为DEBUG,输出详细的调试信息;在⽣产环境中,将⽇志级别设置为ERROR或WARNING,只记录关键的错误和警告信息,避免⽇志⽂件过⼤。
    std::cout 没有内置的⽇志级别管理功能,所有输出都会被打印到控制台,⽆法根据上下⽂灵活控制输出内容。
  • ⽇志格式化
    ⽇志库可以提供灵活的⽇志格式化功能,包括时间戳、⽇志级别、线程信息、⽂件名、⾏号等。如std::cout 输出的内容格式单⼀,没有内置的格式化功能,需要⼿动添加时间戳、⽂件名等信息,代码繁琐且容易出错。
  • ⽇志存储管理
    ⽇志库可以将⽇志信息输出到多种⽬标,如控制台、⽂件、远程服务器等。同时,⽇志库通常⽀持⽇志⽂件的轮转、压缩和归档,⽅便⻓期存储和管理。
    ⽐如设置⽇志⽂件每天⾃动轮转,并在⽂件⼤⼩超过⼀定阈值时进⾏压缩归档。这有助于避免⽇志⽂件过⼤导致磁盘空间不⾜。
    ⽽ std::cout 只能将信息输出到控制台,⽆法直接⽀持⽇志⽂件的存储和管理功能。
  • 线程安全
    在多线程程序中,⽇志库通常提供了线程安全的机制,确保⽇志输出不会出现冲突或数据错乱。在多线程环境下,多个线程可能同时尝试写⼊⽇志。⽇志库通过锁或其他同步机制确保⽇志输出的线程安全。
    std::cout 在多线程环境下可能会出现⽇志输出混乱的问题,需要开发者⼿动实现线程安全机制。
  • 性能优势
    ⽇志库通常会进⾏性能优化,例如通过异步写⼊⽇志、缓冲机制等,减少⽇志输出对程序性能的影响。
    std::cout 是同步操作,每次输出都会阻塞当前线程,可能对程序性能产⽣较⼤影响。
    因此,本项⽬采⽤google的spdlog⽇志库进⾏⽇志管理,为了使⽤⽅便,对spdlog库采⽤单例模式进⾏简单封装。

日志定义

程序在运行过程中,需要将一些重要的信息记录下来,方便开发者、测试人员、运维人员了解程序运行时发生了什么事情。通常会将日志信息写入到控制台、文件、远程服务器中...


日志级别及说明

  • TRACE:最详细的跟踪信息,用于追踪程序执行流程。比如:函数进入和退出、变量的值、程序的执行路径
  • DEBUG:调试信息,帮助开发人员理解程序运行的状态。比如:监控执行关键点、重要变量状态
  • INFO:重要的运行信息,反应程序正常的状态。比如:系统启动成功、配置文件加载成功
  • WARN:潜在的问题,但不影响程序的继续执行。比如:非关键性错误
  • ERROR:程序运行出错,影响特定功能,但程序仍旧可以正常运行。比如:打开文件失败、数据库连接失败
  • CRITICAL:严重错误,可能会导致系统崩溃,无法运行。比如:系统资源耗尽、数据损坏致命错误

日志级别优先级
TRACE < DEBUG < INFO < WARN < ERROR < CRITICAL

mylog.h

mylog.cpp

Provider实现

给模型发消息:

  1. 到官方去获取 api key; DONE
  2. 了解官方提供的给模型发消息的 API---deepseek
  3. 利用 API 给模型发消息
    前面采用 apifox 工具给 deepseek-chat 模型发消息
    现在需要在程序中自己写代码给模型发消息
策略模式

假设你现在要从宿舍去学校图书馆,但宿舍到图书馆之间有⼀段距离,你可以采⽤下属三⽅⽅式去:

  • ⾛路(最节省钱,但慢)
  • 骑⾃⾏⻋(中等速度,中等花销)
  • 坐校内公交⻋(最快,但贵)
    传统方式
c 复制代码
if (今天没钱) {  
	⽤⾛路();  
} else if (今天想省时间) {
	⽤打⻋();  
} else {  
	⽤⾃⾏⻋();  
}

策略方式

c 复制代码
设置策略(打⻋)  
执⾏策略()

策略⽅式实现:

  • 定义⼀个接⼝ TransportStrategy (出⾏策略)。
  • 分别实现 WalkStrategy 、 BikeStrategy 、 TaxiStrategy 。
  • 在运⾏时,你可以随时切换策略:
c 复制代码
class TransportStrategy {  
public:  
	virtual void go() = 0;  
};  

class WalkStrategy : public TransportStrategy {  
public:  
	virtual void go() override { cout << "⾛路去机房🚶"; }  
};  

class BikeStrategy : public TransportStrategy {  
public:  
	virtual void go() override { cout << "骑⻋去机房🚴"; }  
};  

class BusStrategy : public TransportStrategy {  
public:  
	virtual void go() override { cout << "打⻋去机房🚕"; }  
};  

class Student {  
private:  
	TransportStrategy* strategy;  
public:  
	void setStrategy(TransportStrategy* s) { strategy = s; }  
	void goToLab() { strategy->go(); }  
};  

int main(){  
	Student me;
	
	me.setStrategy(new WalkStrategy());  
	me.goToLab(); // 输出: ⾛路去机房🚶  
	
	me.setStrategy(new BusStrategy());  
	me.goToLab(); // 输出: 打⻋去机房🚕  
	
	return 0;  
}

程序⾮常美观且灵活,在使⽤时只需和TransportStrategy打交道,不需要知道背后到底是WalkStrategy、BikeStrategy或BusStrategy。如果想更换模式,只需要更换⼀个具体的策略对象即可,程序基本不需要改动。

策略模式是设计模式的⼀种,它的核⼼思想是它定义了⼀些列算法,将每⼀个算法(或⾏为)封装起来,使它们可以相互替换,⽽不⽤再代码中写⼀堆if-else/switch来决定⽤哪个算法。即把"做事的⽅式"抽象出来,运⾏时根据需要选择哪种⽅式去执⾏。

LLMProvider

DeepSeekProvider、ChatGPTProvider、GeminiProvider 这三个类是实现与具体模型交互的类

而基类 LLMProvider 没有与任何模型建立关联,既然没有和任何模型建立关联,因此 LLMProvider 类就不应该实例化,

所以应该将 LLMProvider 设置成抽象类

后续会借助API的⽅式接⼊DeepSeek、ChatGPT、Gemini等⼤模型,每个⼤模型将来都需要:

a. 初始化

b. 检测模型是否有效

c. 发送消息给模型

d. 获取模型名称

e. 获取模型描述

f. 保存模型的有效状态、API Key、模型描述。

操作基本都是相同的,只是实现细节上稍微不同,因此借助策略模式将接⼊模块架构设计如下:

LLMProvider.h

DeepSeek接入封装

DeepSeek提供API

DeepSeek API Docs

Base URL:https://api.deepseek.com

模型名称:deepseek-chat,实际指向DeepSeek-V3.1模型

DeepSeek的API兼容OPenAI,因此请求和响应参数基本与ChatGPT相同:

deepseek-chat模型的聊天补全接⼝设置如下:

请求URL POST /v1/chat/completions

请求头参数:

字段名称 字段类型 字段说明
Content-Type string application/json
Authorization string "Bearer " + _api_key
请求体参数:
字段名称 字段类型 字段说明
model string 模型名称
messages array 历史对话,内部为每个对话的 object,包含 role 和 content 两个字段
temperature string 采样温度
max_tokens integer 最大 tokens 数
【温度-temperature】
调整AI⽣成内容随机性的参数,温度值越⾼,AI回答越天⻢⾏空;温度越低,回答越保守靠谱。
DeepSeek官⽹建议:
温度 场景
0.0 代码生成 / 数学解题
1.0 数据抽取 / 分析
1.3 通用对话
1.3 翻译
1.5 创意类写作 / 诗歌创作
响应参数:
c 复制代码
{  
	"id":"8b1ca715-9270-429a-b40d-2a644f6e1d3f",  
	"object":"chat.completion",  
	"created":1754880537,  
	"model":"deepseek-chat",  
	"choices":[  
		{  
			"index":0,  
			"message":  
			{  
				"role":"assistant",  
				"content":"我是DeepSeek Chat,由深度求索公司(DeepSeek)开发的智能AI助⼿!✨ 我可以帮你解答问题、提供建议、整理信息,甚⾄陪你聊天。⽆论是学习、⼯作,还是⽇常⽣活中的⼩困惑,都可以来找我聊聊!😊 \n\n有什么我可以帮你的吗?"}, 
			"logprobs":null,  
			"finish_reason":"stop"  
			}  
	],  
	"usage":{  
		"prompt_tokens":5,  
		"completion_tokens":63,  
		"total_tokens":68,  
		"prompt_tokens_details":{  
			"cached_tokens":0  
		},  
		"prompt_cache_hit_tokens":0,  
		"prompt_cache_miss_tokens":5  
	},  
	"system_fingerprint":"fp_8802369eaa_prod0623_fp8_kvcache"  
}
  • ⽆状态服务原则:DeepSeek的API基于⽆状态设计,每次请求视为独⽴会话。若需维护对话连续性,必须由客⼾端主动管理并传递完整上下⽂。这与HTTP协议的⽆状态特性⼀致。
  • 系统提⽰:若需保持⻆⾊设定,如始终以专家⾝份回答,每次请求必须包含系统级指令
  • 对话历史:模型仅处理当前请求中的上下⽂,⽆法关联前序对话
大模型初始化

在使⽤deepseek前,需要先配置好deepseek需要的⼀些参数信息,⽐如api-key、model、temperature、max_tokes等信息,否则⽆法正常使⽤deepseek的api。

DeepSeekProvider.h

DeepSeekProvider.cpp

发送消息-全量返回

在向⼤模型提问时,模型将回答⽂本⼀次性返回。

URL: /v1/chat/completions

参数:

字段名称 字段类型 字段说明
code string 是否成功
msg string 结果描述
data double 响应数据
conversation_id int 会话 id
响应格式:
复制代码
{  
	"id": "d41df5f7-046d-45a3-818c-512b990fff73",  
	"object": "chat.completion",  
	"created": 1756726494,  
	"model": "deepseek-chat",  
	"choices": [  
		{  
			"index": 0,  
			"message": {  
				"role": "assistant",  
				"content": "你的名字是你在注册时使⽤的称呼,或者你可以告诉我你希望我怎么称呼你?😊"  
			},  
			"logprobs": null,  
			"finish_reason": "stop"  
		}  
	],  
	"usage": {  
		"prompt_tokens": 9,  
		"completion_tokens": 20,  
		"total_tokens": 29,  
		"prompt_tokens_details": {  
			"cached_tokens": 0  
		},  
		"prompt_cache_hit_tokens": 0,  
		"prompt_cache_miss_tokens": 9  
	},  
	"system_fingerprint": "fp_feb633d1f5_prod0820_fp8_kvcache"  
}

json 对象

检测响应的 json 对象中是否包含 choices 字段

如果包含,再检测 choices 是否为数组

如果是数组,检测是否为空

取 choices [0] 实际也是一个 json 对象 replyContent

top_p: 用于控制模型生成文本的随机性和多样性。

假设模型要生成下一词,候选词和他们的概率如下:

猫 0.3 狗 0.25 苹果 0.2 跑 0.15 的 0.1

假设:top_p = 0.8

  1. 将候选词按照概率进行降序排序:猫 0.3 狗 0.25 苹果 0.2 跑 0.15 的 0.1
  2. 从猫开始累积概率:猫 0.3 猫 0.3 + 狗 0.25=0.55 猫 0.3 + 狗 0.25 + 苹果 0.2=0.75 猫 0.3 + 狗 0.25 + 苹果 0.2 + 跑 0.15=0.9
  3. 概率池 {猫 狗 苹果} 0.75 < 0.8
  4. 模型会从 {猫 狗 苹果} 中选择下一个词
发送消息-全量返回测试

相对于 deepseek 的服务器来说,sdk 实际就是一个 http 客户端

发送消息的流程 (全量返回)

  1. 检测模型是否可用
  2. 构造请求参数:模型名称、消息列表、温度值、最大 token 数 ---json
  3. 对 json 对象进行序列化
  4. 创建 HTTP 客户端,设置请求头:content-type 认证方式
  5. 给模型发送请求,等待模型的回复
  6. 解析模型的响应结果 --- 反序列化:按照 deepseek 返回的 json 格式组织
  7. 返回模型发送的消息的内容

添加全局环境变量

  • 打开bashrc文件
  • 在文件中添加新的环境变量
  • source使添加的环境变量生效
c 复制代码
vim ~/.bashrc 

# api key 
export deepseek_apikey="你申请的deepseek的api key" 
export chatgpt_apikey="你申请的chat gpt的api key" 
export gemini_apikey="你申请的gemini的api key" 

source ~/.bashrc

注意:httplib库默认使⽤http协议,⽽deepseek的官⽹链接使⽤https协议,因此在编译时需要链接OpenSSL开发库以⽀持SSL/TLS。否则在使⽤httplib创建http客⼾端时报错

将OpenSSL库配置到CMakeList.txt⽂件,否则编译时不会链接OpenSSL库

httplib.h

需要预定义宏

全量返回⽐较适合⽣成⽂档、数据报表之类,⽤⼾⼀次性拿到完整的数据⽂件。但对于聊天场景不是很友好,如果⼤模型⼀次回复内容较多,会让⽤⼾等待时间过⻓,体验不是很好。因此,⼀般聊天场景中,基本使⽤流式响应

流式响应

HTTP协议是严格的"请求-响应"模型,永远是客⼾端发起请求,服务器才能响应,服务器就像个"哑巴",它知道更多内容,但是它⽆法主动告诉你。这种⼀问⼀答的模式对于⼤部分⽹⻚浏览器、数据提交等场景已经⾜够了。

但是有些场景下,服务器需要主动向客⼾端推送⼀些实时数据,⽐如,在看体育直播时,服务器要及时将⽐赛分数、⾦球球员等信息推送给客⼾端;在多⼈在线游戏中,服务器需要实时同步玩家的操作和游戏状态;在使⽤导航类应⽤时,服务器需要实时推动导航信息等。

⼤佬们也发现这个问题了,在2004年的时候IanHickson就提出了SSE概念,Opera浏览器是第⼀个⽀持SSE的,2011年开始,⼀些主流浏览器(Chrome、Firefox、Safari)开始逐步⽀持SSE,2015年时SSE规范才正式成为W3C的标准。

SSE协议

SSE是Server Send Event的缩写,即服务器发送事件,是建⽴在HTTP协议之上的开发标准,允许服务器主动向客⼾端(如浏览器)推送实时数据。

SSE通过单⼀的持久连接实现数据的实时传输,客⼾端⽆需频繁发起请求。
SSE协议特点

  • 单向通信:服务器可以主动推送数据到客⼾端,但客⼾端⽆法直接通过SSE向服务器发送数据
  • 基于HTTP协议:SSE使⽤标准的HTTP协议,⽆需额外的协议或端⼝配置,兼容性好易于实现
  • 轻量级:SSE的实现更简单,代码量少,适合简单的实时数据推送场景
  • ⾃动重连:如果连接断开,浏览器会⾃动尝试重新连接,⽆需开发者⼿动处理重连逻辑
  • ⽀持事件类型:服务器可以发送不同类型事件,客⼾端可以根据事件类型执⾏不同的操作
  • ⽀持消息ID:每条消息可以包含⼀个唯⼀的ID,⽤于断线重连后恢复消息流

数据格式:

每个事件可以包含以下字段:

  • data:消息内容(必须)
  • event:事件类型(可选)
  • id:消息ID(可选)
  • retry:重连时间(可选,单位:毫秒)
c 复制代码
data: Hello, world!  
event: message  
id: 123  
retry: 10000  

data: Another message

每条消息以两个换⾏符 (\n\n) 结束,消息流传输完毕后会有专⻔的结束标记,不同实现结束标记不同,⽐如data:[DONE]

向DeepSeek、ChatGPT、Gemini等⼤模型提问时,这些⼤模型并不是⼀次性将完整回答丢给⽤⼾,⽽是服务器边思考,边主动将思考结果吐(推送)给⽤⼾的,就和打字⼀样⼀点点输出,⽤⼾不需要⻓时间的等待,能及时看到服务器响应的结果,体验⽐较好,这种⽅式称为流式响应。SSE推出后实际不温不⽕,⼤模型爆⽕后,正式⼤模型场景的需要,SSE协议就爆⽕了。

websocket

WebSocket协议

SSE协议有⼀个缺陷就是单向传输,即数据只能由服务器给客⼾端推送,在新闻推送、股票⾏情、体育⽐分等场景是⽐较合适的,因为这些场景客⼾端⽆需给服务器发数据。

但有些场景SSE就束⼿⽆策了。⽐如:你在你们宿舍的微信群⾥发了⼀个消息"谁去⻝堂帮我捎个饭",服务器收到后需要"谁去⻝堂帮我捎个个饭"这条消息主动推送给群中其他⼈,其他⼈收到消息后,就需要发消息回应你⽽不是不闻不问。此处由舍友回复"滚犊⼦",那服务器收到后⼜要推送给其他⼈...

该场景中,不仅需要服务器主动给客⼾端推送消息,也需要客⼾端给服务器发送消息。这种场景下WebSocket协议就派上⽤场了。

SSE与WebSocket区别

特性 SSE WebSocket
通信方向 单向通道:服务器 → 客户端 双向通道:服务器 <=> 客户端
设计目的 服务器主动推送数据 (如新闻、状态更新) 双向实时对话 (如聊天、游戏操作同步)
协议 HTTP 独立的 TCP 协议,咱 HTTP 捂手后升级协议 (ws/wss)
数据格式 纯文本 二进制或文本
自动重连 内置 较需要手动实现
使用场景 实时通知、日志流、LLM 响应等单向场景 实时聊天、多人在线游戏、实时交易等双向交互场景
为什么DeepSeek的助⼿消息使⽤SSE,不使⽤websocket?
答:⼤模型的回复是服务器向客⼾端推送数据的单项数据流,在此期间客⼾端不需要给⼤模型服务器发送消息,⽽SSE刚好是服务器主动单项给客⼾端推送数据,并且实现简单⾼效,因此⼤模型回复通常都使⽤SSE协议。

HTTP普通响应体和流式响应体

普通HTTP响应体中,⼀个响应包含⼀个响应头和⼀个响应体,

在HTTP流式返回响应体中,⼀个响应包含⼀个响应头和多个响应块。在流式返回时,会先返回响应头,然后在逐个返回各个响应体,因此在发送流式响应时,需要在请求参数中告知HTTP服务器,响应头和chunk该如何处理。

参数

在Httplib中,请求参数的定义(部分参数)如下:

response_handler 为响应处理回调函数,实际类型为 std::function<void(const

Response&)> ,如果发起请求时设置该函数,当客⼾端收到完整的HTTP响应头和⼀些体(如果存在)后,会调⽤该函数,并传⼊构造好的Response对象。

content_recevier 内容接收回调函数,是处理流式处理响应的关键,类型为:
function<bool(const char* data, size_t len, uint64_t offset, uint64_t total)>

  • data:指向当前接收到的数据块的指针
  • len:当前数据块的⻓度
  • offset:当前数据块在请求体中的偏移量
  • total:请求体的总⻓度
  • 返回值:true表⽰继续接收数据,false表⽰停⽌接收数据
    设置该回调函数后,客⼾端不会等待整个响应体传输完再存到response.body中,⽽是每收到⼀⼩块数据就⽴刻调⽤该回调函数,处理实时数据,
发送消息-流式返回

URL: /v1/chat/completions

字段名称 正确字段说明
model 调用的大模型名称(如gpt-3.5-turbo
messages 对话消息列表,包含用户提问、历史上下文
temperature 温度参数,控制生成内容的随机性(0-2 之间)
max_tokens 生成内容的最大 token 数限制
stream 是否开启流式响应(SSE 逐字返回)

DeepSeekProvider.cpp

发送消息-流式返回测试

testLLM.cpp

enum class

enum class,C++11新语法

传统 enum 定义的枚举类型:

  1. 枚举类型中的枚举常量不受作用域的限制,一旦定义好之后,枚举常量就具有全局的属性
  2. 类型不安全,编译器会将其隐式转换为整形
    在 C++11 中,通过 class 的方式定义枚举类型,可以解决上述两点不足
相关推荐
We་ct2 小时前
LeetCode 67. 二进制求和:详细题解+代码拆解
前端·数据结构·算法·leetcode·typescript
斌味代码2 小时前
后端实战实战案例
java
小信丶2 小时前
彻底解决 IDEA 启动 SpringBoot 报错:Command line is too long
java·spring boot·intellij-idea
梅西库里RNG2 小时前
Java进阶理解纪要
java·开发语言
liqianpin12 小时前
java进阶1——JVM
java·开发语言·jvm
hsjcjh2 小时前
node.js+npm的环境配置以及添加镜像(保姆级教程)
java
在荒野的梦想2 小时前
LangChain4j 集成若依单体应用 | 5 大 AI 功能实战:多轮对话、流式输出、RAG 知识库
java·人工智能
苏渡苇2 小时前
ConcurrentHashMap.computeIfAbsent():高并发下安全初始化的终极方案
java·安全·jdk·高并发·hashmap·concurrent
yuanyuan2o22 小时前
你可能需要的算法思想——动态规划
数据结构·python·算法·动态规划