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.cpp 及 lib、llm_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 | 串行消费输入,组织推理与保存 | Start、Send、Load;发出 token 与消息回放信号 |
XModels | 模型资源、聊天模板、分词与反分词 | Load、MakeChatPrompt、Tokenize、TokenToString |
XContext | KV/运行状态、token 轨迹和位置 | Init、Prefill、Decode、GetLogits、Save/Load |
XSampler | 根据分数选择 token,记录惩罚历史 | Init、Sample |
XCache | 管理会话目录与三个文件 | CreateByTimeMs、SaveMsg、Load、GetCacheInfos |
TaskList | model/view 展示任务元数据 | Qt::UserRole + 1 保存 CacheInfo,delegate 绘制标题 |
MsgList | 用户纯文本与助手 Markdown 气泡 | AddUserMsg 建立当前助手标签;AddTokenSlot 追加内容 |
UserInput | Enter/Shift+Enter 与按钮发送 | 去除首尾空白,空消息不发送 |
ModelLoad | 显示加载进度 | 比例 0~1 映射为 0~1000,成功槽调用 accept() |
4. 所有权与线程协作
main 先用 unique_ptr<XAIEngine> 持有引擎,再由 AgentMain::Init 的 std::move 接管。引擎与会话都用 shared_ptr<XModels> 持有模型,避免每次新建会话重复加载权重。
当前会话由 AgentMain::session_ 持有。会话内部有自己的上下文、采样器和缓存。模型可以共享,但不同会话的 KV、位置与采样历史应分别管理。Qt 子控件通常以父对象建立所有权;MsgList::message_ 是指向子控件的借用指针,不能单独释放。
| 执行环境 | 执行内容 | 协作约束 |
|---|---|---|
| GUI 主线程 | 窗口、输入、列表、气泡、历史回放调用 | QWidget 只在本线程操作 |
| 引擎加载线程 | XModels::Load 与加载回调 | 用 InitProgress/InitFinished/InitFailed 通知 GUI |
| 当前会话线程 | 创建上下文与采样器,消费队列、推理、保存 | condition_variable 等待消息;锁外执行耗时工作 |
带 this 接收上下文的 Qt 连接,会把工作线程发出的信号投递给 GUI 线程。std::string、ChatMsg、CacheInfo 的 Qt 参数传递依赖对应元类型支持,移植 Qt 版本或增加类型时应核对注册和日志。QObject 的线程归属不意味着成员函数自动在该线程执行,普通的 session_->Load(...) 仍在调用者线程运行。
Send 使用 mutex + queue + condition_variable,锁内只入队,锁外唤醒。消费线程的等待谓词为“队列非空或停止”。析构时设置停止标志、唤醒等待、join,但必须等正在执行的底层调用返回。当前 Load 不经消息队列,也没有锁,所以不能认为整个 XSession 都线程安全。
AgentMain 的成员逆序析构让会话先于引擎释放。单独使用 XContext 时还应保证模型活到上下文释放完成:目前其 Pimpl 中 context_ 声明早于 model_,成员逆序析构会先减去模型引用,不能仅凭这一个成员引用假定释放顺序已完善。
5. 启动与会话时序
5.1 应用启动
- 创建
QApplication,从:/image/app.qss加载内嵌样式,注册错误级日志回调。 - 创建引擎与进度对话框;模型路径优先取
argv[1],否则使用models\Qwen3.5-0.8B-Q4_K_M.gguf。 XAIEngine::Init复制配置,创建模型对象,在独立线程中加载。- 连接进度与完成信号,执行
load.exec()接收事件。 - 构造主窗口,移动引擎所有权,扫描缓存目录并启动空会话。
QApplication::exec()进入正式 GUI 事件循环。
当前先启动加载再连接信号;InitFailed 尚未接入对话框;对话框被拒绝后仍进入主窗口。安装前必须确认模型与 DLL 路径,错误窗口行为不能视为完整产品状态机。
5.2 一轮输入的实际执行顺序
UserInput发出SendMsg(QString)。AgentMain将文本转换为std::string入队,立即显示用户气泡和空助手气泡。- 消费线程取出消息;首次发送创建毫秒时间戳目录,保存用户消息并通知刷新任务列表。
- 首轮携带系统提示;已有会话清空本轮 system,继续使用上下文内的历史。
MakeChatPrompt应用模型内嵌模板,追加<think>或闭合标记;Tokenize产生 token 列表。Prefill把全部 prompt token 一次提交给llama_decode,更新 token 轨迹与下一位置。- 从 logits 采样首个 token;当前首 token 只转换为字符串,没有进入 UI 和累计回复。
- 循环:解码上一 token → 取新 logits → 采样下一 token → 检查 EOG → 发送片段并累计文本。
- 遇到 EOG 时把结束 token 写入上下文后退出;Decode 失败或停止标志也会退出。
- 保存助手文本和运行状态,再等待下一条输入。
这解释了“屏幕上的文本”和“上下文里实际存在的 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_ctx | 0 | 桌面设置为 4096,封装示例设置 2048 |
n_batch / n_ubatch | 4096 / 512 | 逻辑 batch 与后端 micro-batch,不是生成长度 |
n_threads / n_threads_batch | 8 / 16 | 单步解码与批处理线程数 |
k_type / v_type | F16 / F16 | KV 类型枚举与该版本 ggml 数值对应 |
temperature / top_k / top_p | 0.5 / 40 / 0.95 | GUI 暂无调节界面 |
seed | 0 | 固定种子;随机哨兵是 0xFFFFFFFF |
penalty_last_n / repeat | 256 / 1.2 | 回看生成历史的窗口和重复惩罚 |
penalty_freq / present | 0.5 / 0.3 | 频率与存在惩罚 |
think | true | 桌面会话明确改为 false |
7. 会话磁盘格式
根路径固定为相对工作目录的 ./cache;不是自动定位到可执行文件目录。每个会话目录名是 Unix 时间的毫秒数。时间戳可读,但同一毫秒内创建多个会话会有碰撞可能,未使用 UUID。
cache/{id}/
├─ cache_info.md 首条消息原文,列表只读取第一行作为标题
├─ msg.md 二进制追加记录,不能按 Markdown 编辑
└─ kv.bin llama 运行状态及 token 轨迹
一条 msg.md 记录依次包含:
| 字段 | 字节数 | 含义 |
|---|---|---|
role | 1 | S 系统、U 用户、A 助手 |
len | sizeof(int) | 正文 UTF-8 字节长度,以本机整数表示保存 |
content | len | 原始字节,无结尾零字节 |
本机 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/linux 与 cpu/cuda/vulkan 选择 SDK;默认 BUILD_SHARED_LIBS=ON,其他示例开关默认关闭。BUILD_TEST_XLLM=ON 要同时开启 BUILD_XLLM=ON。BUILD_LLAMA=ON 会在配置阶段执行上游配置、编译和安装。
桌面文档显式使用 BUILD_SHARED_LIBS=OFF,使自有 xllm 静态链接,避开未设置 LLAMA_INSTALL_DIR 时的动态输出路径问题。Qt 与预编译 llama 仍是动态依赖,不能省略 DLL。Qt 部署脚本与 lib/bin 中的后端复制是两条不同链路,详见安装文档。
9. 已知限制与改进顺序
以下均来自当前代码阅读;它们不是本次新增功能,也不代表已修复。
| 编号 | 位置 | 影响与后续方向 |
|---|---|---|
| D01 | main / XAIEngine | 先启动后连接、失败信号未处理;增加显式加载状态并先连接信号 |
| D02 | XModels::Load | 忽略进度回调 bool;取消加载不能传递到底层 |
| D03 | XSession::Load | GUI 直接修改上下文/缓存,与推理线程竞争;应把恢复作为队列命令 |
| D04 | XSession / MsgList | 首 token 丢失;按“采样→检查→输出→解码”统一生成步骤 |
| D05 | XSession | 模板、分词、Prefill、缓存返回值未统一处理;增加错误信号与清理路径 |
| D06 | XContext | 不分块、不检查空输入和容量;复用 batch 未清旧 logits 标志 |
| D07 | MsgList | 连续发送会覆盖当前助手标签,旧回复可能进入新气泡;使用消息 ID 或生成期禁用发送 |
| D08 | TokenToString → QString | UTF-8 字符可能跨 token,需累计完整字节后解码 |
| D09 | XCache | 二进制记录缺少边界校验/版本,三个文件无事务;增加校验、原子替换和模型指纹 |
| D10 | XSampler / XCache | 恢复不包括 RNG 和惩罚历史,不保证完全复现 |
| D11 | Start / Init / 析构 | 不支持重复启动;join 可能阻塞 GUI,停止标志修改未与条件变量等待共用同一锁协议 |
| D12 | UserInput / TaskList | Enter 未消费、目录未排序、标题未截断;需要独立交互修复 |
| D13 | llmclass CMake / sh | LLAMA_INSALL_DIR 拼写、子进程退出码、硬编码路径和脚本引号问题 |
| D14 | XContextImpl | 独立使用时模型/上下文释放顺序需调整,确保上下文先释放 |
| D15 | testllm | 当前 CPU 路径的 llama_sampler_sample 已执行 accept,示例额外 accept 会重复记录惩罚历史 |
| D16 | XCache::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 如何区分接口承诺与调用要求
@brief:函数做什么,是否仅创建对象、真正加载模型或启动后台工作。@param / @return:输入输出的类型含义、单位、所有权,以及失败如何表达。@pre:调用方必须先满足的条件,不代表函数内部已经有对应检查。@note:当前实现的边界、线程限制、异常路径或课程版本尚未完善之处。- 成员变量注释:区分拥有资源的智能指针、Qt 父子控件树和临时借用指针。
例如 XSession::Start() 正常创建线程后立即返回 true,但上下文可能稍后初始化失败;真正就绪应看 StartFinished。再如 XCache::SaveMsg() 未检查最后的流错误,不能把返回 true 解释成可靠的多文件提交保证。
11.2 Qt Agent 的重点阅读位置
| 想弄清的问题 | 对应源码 | 注释讲解重点 |
|---|---|---|
| 程序为何先出现进度框再进入聊天窗口 | main.cpp / modelload.h/.cpp | QApplication 生命周期、模态事件循环、进度比例、accept 的作用 |
| 界面为何分成多个 root/head/body/work | agentmain.h/.cpp | 布局层次、控件 parent、QSS objectName、伸展比例与圆角 |
| 无边框窗口如何跟随鼠标移动 | AgentMain::eventFilter | 全局坐标与抓取偏移,带数值例子说明相减关系 |
| 后台加载为何还能更新界面 | xaiengine.h/.cpp | std::thread 与 QObject 线程归属、回调桥接、Qt 排队通知 |
| 输入队列如何让 GUI 不等待模型计算 | xsession.h/.cpp | unique_lock、wait 释放锁、谓词、出队作用域与锁外推理 |
| 每个 token 如何成为显示文本 | XSession::Start / msglist.h/.cpp | logits → ID → 字节片段 → QString → QLabel,UTF-8 与消息归属边界 |
| 历史任务点击后如何恢复 | tasklist.h/.cpp / AgentMain / XSession::Load | QVariant 自定义角色、CacheInfo.id、磁盘读取与整条消息回放 |
| 发送键与换行如何区分 | userinput.h/.cpp | 键码、修饰位检查、trim、clear、emit 和事件继续传递 |
| 新建会话时旧资源何时销毁 | AgentMain::NewSession / XSession 析构 | shared_ptr 最后引用、断开信号、已排队事件、停止与 join |
11.3 三组容易混淆的概念
unique_lock 与 lock_guard:消费者等待条件变量时,需要临时解锁并在唤醒后重新加锁,所以使用 unique_lock;生产者仅短暂入队,用作用域结束就解锁的 lock_guard。原子停止标志只能保证自身访问原子,不能代替完整的条件变量谓词同步协议。
token ID、piece 与 logits:ID 是词表整数编号,piece 是该编号对应的字节片段,logits 是对候选编号的分数。一个 token 不保证等于一个汉字;分数不是已经归一化的概率;GetLogits 返回借用内存而不是可长期保存的副本。
apply 与 accept: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 源文档