课程访问 cpp.show

夏曹俊老师微信 cppxcj

老夏课堂C++ AI · 课程源码
VIDEO COURSE / 20260905 · 公开文档

xai_agent 设计文档

视频课程版本:20260905。按本目录实际源码整理,注释与文档核对日期:2026-09-05。
配套阅读:安装文档 · HTML 版 · 文档首页

1. 项目定位与范围

本项目是一个以 C++17、Qt Widgets 和 llama.cpp 为基础的本地大模型聊天教学程序。用户加载 GGUF 模型后,可新建任务、输入问题、查看流式回复,并把会话运行状态和消息保存到磁盘。

界面名称包含“智能体”,但当前实现的业务链路是本地文本对话。尚未实现工具调用、任务规划、RAG、联网检索、附件解析、语音、模型下载器或用户权限系统。应把这些能力作为后续扩展,不能从项目名称推断已经支持。

本次注释覆盖 27 个自有 C++ 源文件(15 个桌面程序文件、10 个 xllm 文件、2 个控制台示例),以及 5 个 CMake 文件、7 个构建脚本和 4 个界面/资源文本文件。third_party/llmclass 中的封装与示例属于范围;third_party/llmclass/third_party/llama.cpplibllm_sdk 中复制的上游头文件不作修改。本次保留可执行代码行为,仅补充或纠正注释。

2. 目录与阅读顺序

xai_agent/
├─ main.cpp                   应用入口、加载对话框、事件循环
├─ agentmain.{h,cpp,ui}       主窗口与信号连接
├─ xaiengine.{h,cpp}          模型加载线程、会话工厂
├─ xsession.{h,cpp}           输入队列、推理线程、会话缓存
├─ tasklist.{h,cpp}           历史任务列表
├─ msglist.{h,cpp}            聊天气泡与流式显示
├─ userinput.{h,cpp}          键盘输入与发送按钮
├─ modelload.{h,cpp}          模型加载进度
├─ image/ + xai.qrc          样式、图标与 Qt 资源
├─ CMakeLists.txt             Qt 桌面构建入口
├─ install.cmd               原课程打包脚本
├─ lib/                      桌面使用的预编译 llama SDK
├─ rel/                      原有运行包、模型与历史缓存
├─ third_party/llmclass/
│  ├─ xllm/                  XModels / XContext / XSampler / XCache
│  ├─ test_xllm/              封装组合调用示例
│  ├─ testllm/                直接 C API 性能示例
│  ├─ sh/                    CPU / CUDA / Vulkan 构建脚本
│  ├─ llm_sdk/               按平台和后端划分的 SDK
│  └─ third_party/llama.cpp/  原样保留的上游依赖
├─ docs/                     Markdown 与离线 HTML 文档
└─ tools/build_docs.py        从 Markdown 生成 HTML

建议先读 main.cpp → AgentMain → XAIEngine → XSession,再读 XModels → XContext → XSampler → XCache。最后对照两个控制台示例,区分 GUI 信号协作与底层 C API 调用。源文件中保留的旧实验注释块不参与编译,不能当作当前调用链。

3. 分层结构

GUI 主线程
UserInput ──SendMsg──> AgentMain ──Send──> XSession 输入队列
                         ▲                    │
             MsgList / TaskList              ▼
                         ▲             会话工作线程
                         └── Qt 信号 ── XModels → XContext → XSampler
                                              │
                                              ▼
                                           XCache
                                              │
                                        ./cache/{id}/

XAIEngine ──加载线程──> 共享 XModels ──> llama_model
每个 XSession ──> 独立 XContext / XSampler / XCache
模块职责关键接口/行为
AgentMain布局、窗口拖动、选择历史、新建会话Init 移动引擎所有权;NewSession 替换当前会话
XAIEngine加载模型并共享权重Init 异步加载;CreateSession 仅构造会话
XSession串行消费输入,组织推理与保存StartSendLoad;发出 token 与消息回放信号
XModels模型资源、聊天模板、分词与反分词LoadMakeChatPromptTokenizeTokenToString
XContextKV/运行状态、token 轨迹和位置InitPrefillDecodeGetLogitsSave/Load
XSampler根据分数选择 token,记录惩罚历史InitSample
XCache管理会话目录与三个文件CreateByTimeMsSaveMsgLoadGetCacheInfos
TaskListmodel/view 展示任务元数据Qt::UserRole + 1 保存 CacheInfo,delegate 绘制标题
MsgList用户纯文本与助手 Markdown 气泡AddUserMsg 建立当前助手标签;AddTokenSlot 追加内容
UserInputEnter/Shift+Enter 与按钮发送去除首尾空白,空消息不发送
ModelLoad显示加载进度比例 0~1 映射为 0~1000,成功槽调用 accept()

4. 所有权与线程协作

main 先用 unique_ptr<XAIEngine> 持有引擎,再由 AgentMain::Initstd::move 接管。引擎与会话都用 shared_ptr<XModels> 持有模型,避免每次新建会话重复加载权重。

当前会话由 AgentMain::session_ 持有。会话内部有自己的上下文、采样器和缓存。模型可以共享,但不同会话的 KV、位置与采样历史应分别管理。Qt 子控件通常以父对象建立所有权;MsgList::message_ 是指向子控件的借用指针,不能单独释放。

执行环境执行内容协作约束
GUI 主线程窗口、输入、列表、气泡、历史回放调用QWidget 只在本线程操作
引擎加载线程XModels::Load 与加载回调InitProgress/InitFinished/InitFailed 通知 GUI
当前会话线程创建上下文与采样器,消费队列、推理、保存condition_variable 等待消息;锁外执行耗时工作

this 接收上下文的 Qt 连接,会把工作线程发出的信号投递给 GUI 线程。std::stringChatMsgCacheInfo 的 Qt 参数传递依赖对应元类型支持,移植 Qt 版本或增加类型时应核对注册和日志。QObject 的线程归属不意味着成员函数自动在该线程执行,普通的 session_->Load(...) 仍在调用者线程运行。

Send 使用 mutex + queue + condition_variable,锁内只入队,锁外唤醒。消费线程的等待谓词为“队列非空或停止”。析构时设置停止标志、唤醒等待、join,但必须等正在执行的底层调用返回。当前 Load 不经消息队列,也没有锁,所以不能认为整个 XSession 都线程安全。

AgentMain 的成员逆序析构让会话先于引擎释放。单独使用 XContext 时还应保证模型活到上下文释放完成:目前其 Pimpl 中 context_ 声明早于 model_,成员逆序析构会先减去模型引用,不能仅凭这一个成员引用假定释放顺序已完善。

5. 启动与会话时序

5.1 应用启动

  1. 创建 QApplication,从 :/image/app.qss 加载内嵌样式,注册错误级日志回调。
  2. 创建引擎与进度对话框;模型路径优先取 argv[1],否则使用 models\Qwen3.5-0.8B-Q4_K_M.gguf
  3. XAIEngine::Init 复制配置,创建模型对象,在独立线程中加载。
  4. 连接进度与完成信号,执行 load.exec() 接收事件。
  5. 构造主窗口,移动引擎所有权,扫描缓存目录并启动空会话。
  6. QApplication::exec() 进入正式 GUI 事件循环。

当前先启动加载再连接信号;InitFailed 尚未接入对话框;对话框被拒绝后仍进入主窗口。安装前必须确认模型与 DLL 路径,错误窗口行为不能视为完整产品状态机。

5.2 一轮输入的实际执行顺序

  1. UserInput 发出 SendMsg(QString)
  2. AgentMain 将文本转换为 std::string 入队,立即显示用户气泡和空助手气泡。
  3. 消费线程取出消息;首次发送创建毫秒时间戳目录,保存用户消息并通知刷新任务列表。
  4. 首轮携带系统提示;已有会话清空本轮 system,继续使用上下文内的历史。
  5. MakeChatPrompt 应用模型内嵌模板,追加 <think> 或闭合标记;Tokenize 产生 token 列表。
  6. Prefill 把全部 prompt token 一次提交给 llama_decode,更新 token 轨迹与下一位置。
  7. 从 logits 采样首个 token;当前首 token 只转换为字符串,没有进入 UI 和累计回复。
  8. 循环:解码上一 token → 取新 logits → 采样下一 token → 检查 EOG → 发送片段并累计文本。
  9. 遇到 EOG 时把结束 token 写入上下文后退出;Decode 失败或停止标志也会退出。
  10. 保存助手文本和运行状态,再等待下一条输入。

这解释了“屏幕上的文本”和“上下文里实际存在的 token”可能不一致。文档描述的是现有实现,并未在注释任务中修复首 token 丢失行为。

6. llmclass 接口约定

6.1 模型与模板

XModels 使用 Pimpl 隔离底层实现;llama_model_ptr 负责释放模型。后端通过 std::call_once 初始化。NativeModel() 返回借用指针,不转移所有权;上下文只能在模型加载成功后创建。

MakeChatPrompt 接收本轮 system/user/think,调用模型的 chat template,并为助手追加生成起始位置。缓冲不足时按返回长度重试。手工拼接的 <think> 标记依赖模型约定,不构成所有 GGUF 模型通用的思考控制接口。

分词先试用当前缓冲,负数返回值的绝对值表示所需容量;反分词先使用 128 字节缓冲,必要时扩容。token piece 可能只是一个 UTF-8 字符的一部分,GUI 当前逐片段转 QString,尚无跨 token 字节缓冲。

6.2 上下文、batch 与 logits

XBatch 为单序列 seq_id=0 填充 token 与绝对位置。index_ 表示下一可写位置,tokens_ 保存成功提交的 token;成功 decode 后才推进二者。桌面会话每轮都追加到已有上下文,不会每次从位置 0 重算。

Prefill 当前不按 n_batch 自动切分,也没有空输入、剩余容量或滑动窗口处理。GetLogits 使用底层 llama_get_logits,返回非拥有视图。通常仅请求最后一位置输出,但 batch 复用分支未清除旧 logits 标志;若产生多行 logits,不能把“当前返回指针必定指向最后行”作为通用保证。

XLogits.data 不能释放或长期保存;必须在上下文再次 decode/load 或销毁之前完成采样。保存文件时同时提交底层状态和 token 轨迹;加载成功后按实际读入长度恢复 index_

6.3 采样链

当前封装顺序为 top-k → top-p → temperature → penalties(可选)→ dist。采样器复制词表分数到候选数组,调用链修改候选,再用 data[selected].id 取回真正 token ID。selected 是候选索引,不是词表 ID。

Sample 内调用 llama_sampler_accept 记录已生成 token。调用者不要把成功返回值再次 accept;prompt 和从磁盘读取的历史不会自动进入惩罚历史。缓存也不保存随机数生成器状态,因此恢复会话不保证逐 token 复现原运行。

配置封装默认值桌面实际覆盖/说明
gpu_layer-1请求卸载,实际由 SDK 后端和设备决定;CPU 包仍走 CPU
n_ctx0桌面设置为 4096,封装示例设置 2048
n_batch / n_ubatch4096 / 512逻辑 batch 与后端 micro-batch,不是生成长度
n_threads / n_threads_batch8 / 16单步解码与批处理线程数
k_type / v_typeF16 / F16KV 类型枚举与该版本 ggml 数值对应
temperature / top_k / top_p0.5 / 40 / 0.95GUI 暂无调节界面
seed0固定种子;随机哨兵是 0xFFFFFFFF
penalty_last_n / repeat256 / 1.2回看生成历史的窗口和重复惩罚
penalty_freq / present0.5 / 0.3频率与存在惩罚
thinktrue桌面会话明确改为 false

7. 会话磁盘格式

根路径固定为相对工作目录的 ./cache;不是自动定位到可执行文件目录。每个会话目录名是 Unix 时间的毫秒数。时间戳可读,但同一毫秒内创建多个会话会有碰撞可能,未使用 UUID。

cache/{id}/
├─ cache_info.md   首条消息原文,列表只读取第一行作为标题
├─ msg.md          二进制追加记录,不能按 Markdown 编辑
└─ kv.bin          llama 运行状态及 token 轨迹

一条 msg.md 记录依次包含:

字段字节数含义
role1S 系统、U 用户、A 助手
lensizeof(int)正文 UTF-8 字节长度,以本机整数表示保存
contentlen原始字节,无结尾零字节

本机 Windows x64 的 int 为 4 字节小端;文件无 magic、版本、校验和和可移植字节序定义。例如用户发送 ASCII Hi,记录是 55 02 00 00 00 48 69。中文长度按 UTF-8 字节数计算,不是字符个数。

SaveMsg 先覆盖 kv.bin,再尝试写标题,最后追加消息。GUI 在用户 prefill 前保存用户消息,在生成结束后保存助手消息,因此中断时文本与运行状态可能不一致。三份文件不是事务,失败不会自动回滚;当前还会把消息内容打印到标准输出。

Load 先恢复 KV/运行状态,再读二进制消息;任一步失败都可能返回空列表。没有模型指纹验证、路径限制、长度上限和完整读取校验,只应使用本程序生成的可信缓存。模型、SDK 或上下文配置变更后,应使用新会话目录,不应把旧 kv.bin 当作通用聊天历史格式。

8. 构建与部署设计

桌面根 CMake 先找到 Qt,再把查找前缀切换为 lib/,导入 llama,仅加入 third_party/llmclass/xllm。它不会执行 llmclass 根工程的 BUILD_LLAMA、CUDA/Vulkan 分支。顶层声明 CMake 3.19,但 xllm 子目录需要 3.22;整体应按更高要求准备。

独立 llmclass 根工程按 msvc/linuxcpu/cuda/vulkan 选择 SDK;默认 BUILD_SHARED_LIBS=ON,其他示例开关默认关闭。BUILD_TEST_XLLM=ON 要同时开启 BUILD_XLLM=ONBUILD_LLAMA=ON 会在配置阶段执行上游配置、编译和安装。

桌面文档显式使用 BUILD_SHARED_LIBS=OFF,使自有 xllm 静态链接,避开未设置 LLAMA_INSTALL_DIR 时的动态输出路径问题。Qt 与预编译 llama 仍是动态依赖,不能省略 DLL。Qt 部署脚本与 lib/bin 中的后端复制是两条不同链路,详见安装文档。

9. 已知限制与改进顺序

以下均来自当前代码阅读;它们不是本次新增功能,也不代表已修复。

编号位置影响与后续方向
D01main / XAIEngine先启动后连接、失败信号未处理;增加显式加载状态并先连接信号
D02XModels::Load忽略进度回调 bool;取消加载不能传递到底层
D03XSession::LoadGUI 直接修改上下文/缓存,与推理线程竞争;应把恢复作为队列命令
D04XSession / MsgList首 token 丢失;按“采样→检查→输出→解码”统一生成步骤
D05XSession模板、分词、Prefill、缓存返回值未统一处理;增加错误信号与清理路径
D06XContext不分块、不检查空输入和容量;复用 batch 未清旧 logits 标志
D07MsgList连续发送会覆盖当前助手标签,旧回复可能进入新气泡;使用消息 ID 或生成期禁用发送
D08TokenToString → QStringUTF-8 字符可能跨 token,需累计完整字节后解码
D09XCache二进制记录缺少边界校验/版本,三个文件无事务;增加校验、原子替换和模型指纹
D10XSampler / XCache恢复不包括 RNG 和惩罚历史,不保证完全复现
D11Start / Init / 析构不支持重复启动;join 可能阻塞 GUI,停止标志修改未与条件变量等待共用同一锁协议
D12UserInput / TaskListEnter 未消费、目录未排序、标题未截断;需要独立交互修复
D13llmclass CMake / shLLAMA_INSALL_DIR 拼写、子进程退出码、硬编码路径和脚本引号问题
D14XContextImpl独立使用时模型/上下文释放顺序需调整,确保上下文先释放
D15testllm当前 CPU 路径的 llama_sampler_sample 已执行 accept,示例额外 accept 会重复记录惩罚历史
D16XCache::SaveMsg未检查 write/close 后流状态,返回 true 不等于数据已可靠持久化

改进时优先处理失败与生命周期、生成正确性和会话串行化;其次完善缓存完整性、上下文容量和 UI 状态。工具调用、检索等能力应建立在稳定的会话基础上,通过新的接口与明确的数据契约接入。

10. 文档与注释维护

Markdown 是文档内容源;HTML 从相同 Markdown 生成,提供目录、页间导航、可滚动代码/表格、移动布局和打印样式,无 CDN 或脚本依赖。运行 python tools/build_docs.py 更新 HTML;加入 --site-root C:\code\cppwww 可同步公开副本。生成器只覆盖本专题的文档文件,不调整站点数据库。

HTML 公开入口为 /articles/cpp-ai-course/xai-agent-20260905/index.html,同时在 platform-modules.json 的 C++ AI 分类登记。当前公开直访无需登录;未来若改为会员资料,应把正文移出公开目录并通过服务端鉴权提供,不能只隐藏链接。

注释验证以原始 Git 基线 97f3cc6 为参照:比较 C++ 词法 token、构建脚本中的非注释内容与资源有效内容,确认可执行逻辑不变;同时核对排除目录的文件摘要。构建和页面验证范围记录在安装文档末尾。

11. 细化注释阅读指南

第二轮整理在已有注释上继续细化全部 27 个自有 C++ 文件,重点展开 Qt Agent 的 15 个文件。头文件使用 @brief@param@return@pre@note 说明接口,实现文件解释关键步骤的目的与数据变化;原有可执行代码仍保持不变。

11.1 如何区分接口承诺与调用要求

例如 XSession::Start() 正常创建线程后立即返回 true,但上下文可能稍后初始化失败;真正就绪应看 StartFinished。再如 XCache::SaveMsg() 未检查最后的流错误,不能把返回 true 解释成可靠的多文件提交保证。

11.2 Qt Agent 的重点阅读位置

想弄清的问题对应源码注释讲解重点
程序为何先出现进度框再进入聊天窗口main.cpp / modelload.h/.cppQApplication 生命周期、模态事件循环、进度比例、accept 的作用
界面为何分成多个 root/head/body/workagentmain.h/.cpp布局层次、控件 parent、QSS objectName、伸展比例与圆角
无边框窗口如何跟随鼠标移动AgentMain::eventFilter全局坐标与抓取偏移,带数值例子说明相减关系
后台加载为何还能更新界面xaiengine.h/.cppstd::thread 与 QObject 线程归属、回调桥接、Qt 排队通知
输入队列如何让 GUI 不等待模型计算xsession.h/.cppunique_lock、wait 释放锁、谓词、出队作用域与锁外推理
每个 token 如何成为显示文本XSession::Start / msglist.h/.cpplogits → ID → 字节片段 → QString → QLabel,UTF-8 与消息归属边界
历史任务点击后如何恢复tasklist.h/.cpp / AgentMain / XSession::LoadQVariant 自定义角色、CacheInfo.id、磁盘读取与整条消息回放
发送键与换行如何区分userinput.h/.cpp键码、修饰位检查、trim、clear、emit 和事件继续传递
新建会话时旧资源何时销毁AgentMain::NewSession / XSession 析构shared_ptr 最后引用、断开信号、已排队事件、停止与 join

11.3 三组容易混淆的概念

unique_locklock_guard:消费者等待条件变量时,需要临时解锁并在唤醒后重新加锁,所以使用 unique_lock;生产者仅短暂入队,用作用域结束就解锁的 lock_guard。原子停止标志只能保证自身访问原子,不能代替完整的条件变量谓词同步协议。

token IDpiecelogits:ID 是词表整数编号,piece 是该编号对应的字节片段,logits 是对候选编号的分数。一个 token 不保证等于一个汉字;分数不是已经归一化的概率;GetLogits 返回借用内存而不是可长期保存的副本。

applyaccept:apply 进行本步候选选择,accept 更新采样器历史。XSampler::Sample 内部完成二者;直接 C API 的 llama_sampler_sample 在本版本常规 CPU 路径也已经 accept。示例里额外一次 accept 的现有问题已经在对应位置注明。

11.4 llmclass 的实现细节

模型层进一步说明两层 user_data 回调桥、c_str 借用寿命、输出缓冲扩容及 resize/reserve 的区别。上下文层说明 batch 的容量与有效数量、绝对 token 位置、单序列 ID、成功推进轨迹,以及状态文件恢复后的定位。

采样层说明候选数组、logit 与概率的区别、selected 索引到词表 ID 的转换,以及链中子采样器的所有权。缓存层逐段解释 role、长度整数和正文原始字节的写入/读取,并区分目录标题扫描与完整状态恢复。两个控制台示例也补充了各自配置、保存时机、计时口径和结束行为的差异。

下载 Markdown 源文档