1. QTextEdit 当运行日志输出区:内存涨到多少会崩?
1.1 症状:append 一时爽,内存火葬场
QTextEdit 当运行日志输出区,时间一长内存狂涨最后崩掉,我决定让 Codex 来改上限删行逻辑。Codex 的模型通道我接的是 TaoToken,在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=qtextedit_codex 创建 Key 后就能用。接下来按原始需求把 blockCount() 超限判断和 QTextCursor 删除逻辑交给 Codex 检查。本文记录完整排障过程。
最早遇到这个问题,是在一个 Qt 桌面工具里把 QTextEdit 当作调试输出窗。为了不影响交互,控件设置了setReadOnly(true),然后所有日志都走同一个追加函数。一开始很爽:textEdit->append()每次自动换行,时间戳拼在行首,滚动输出跟终端一模一样。但程序连续跑三四个小时后,任务管理器里看到进程内存从几十 MB 一路涨到 1.2GB 甚至更多,最后 Qt 直接弹出崩溃对话框。
拆开看原因并不复杂。QTextEdit内部用QTextDocument按 block 组织文本,每次append都会新增一个 block。只要程序不主动清理,block 数量就只增不减,对应的文本、格式、布局缓存全部驻留内存。日志输出越频繁,内存涨得越快,最终把堆内存耗尽。
1.2 为什么简单的clear()不解决问题
有人说那就定时clear()清空好了,但这会把正在排查的历史日志全部丢掉,用户刚想回看前面的报错就没有了。我们要的是“超过最大行数,只删除最旧的行”,保留最近几千行。这就需要精确控制QTextCursor的选区:选中文档开头到“应删除的最后一行”之间的所有文本,然后调用removeSelectedText()。
判断行数靠document->blockCount(),这是 QTextDocument 给你现成的行数计数器。设一个maxRowNumber,当blockCount()超过它,就删除前面的rowCount - maxRowNumber行。逻辑听起来简单,真正写起来却不顺手:如果你用cursor.select(QTextCursor::LineUnderCursor)或BlockUnderCursor去选,会发现每次只能删掉第一行,删完第一行还在,留下一个空段落,程序还照样继续涨。
这个坑让我决定把代码交给 Codex 审一遍,而不是自己继续按直觉试。要让 Codex 干活,得先解决模型通道问题。
2. 让 Codex 有模型可用:TaoToken 的 Key 和 Base URL 这样配
2.1 在官网注册并创建 API Key
Codex 是一个靠模型驱动的排障助手,但模型接口的额度、可用性经常让人头疼。我这边接的是 TaoToken 作为统一 API 通道,先打开 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=qtextedit_codex 注册账号,进入控制台创建 API Key。
创建成功后,Key 会显示一次,复制下来保存为YOUR_API_KEY。这个 Key 等会儿要填到 Codex 的配置里,不要截图、不要贴到公开代码仓库。还需要记一个重要的地址:https://taotoken.net/api。注意它末尾没有/v1,很多 AI 工具默认会在 Base URL 后面补/v1,但这里不需要你手动加,按原样填就行。
官网落地页和处理模型调用的接口地址是两回事:前者是给人看的控制台和模型广场,用来创建 Key、看用量;后者是给代码填的 API 地址,不能混在一起。后面排障时如果遇到 404,先回来检查是不是把页面地址https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=qtextedit_codex当成接口地址填了。
2.2 Codex 的 config.toml 接入配置
Codex 支持通过配置文件指定模型供应商。我这边用的是本地~/.codex/config.toml,在原来内容基础上新增一个 provider 块,再把默认模型指到 TaoToken 的模型 ID 上。示例如下:
model = "MODEL_ID" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"其中MODEL_ID不要自己编,以 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=qtextedit_codex 的模型广场当时列出的 ID 为准。不同时间上架的模型可能不同,直接复制列表里的标识符最稳。
配置文件写好后,在终端里导出环境变量:
export TAOTOKEN_API_KEY=YOUR_API_KEY再启动 Codex,就能看到它通过 TaoToken 通道请求模型了。如果不想用环境变量,也可以把 Key 直接写进env_key对应的字段,但那样 Key 会以明文存在配置文件里,安全性差一些。我习惯用环境变量,切换 Key 时不用改文件。
3. 把“超限删行”这个需求精确说给 Codex
3.1 我给 Codex 的问题描述
模型通道通完,接下来才是正事。我打开 Codex 对话,把原始需求拆成几个要点贴给它,没有让它自由发挥。问题描述是这样写的:
“我的 Qt 程序用 QTextEdit 作为日志输出区,控件是只读的。每次追加一行日志调用 append(),长时间运行后 document 的 blockCount() 超过十万行,内存到 1.2GB 会崩溃。我想在追加函数里设置上限:当 blockCount() 大于 maxRowNumber 时,删除最前面的 rowCount - maxRowNumber 行。之前我尝试用 QTextCursor 从文档开头向下移动并选中要删的行,但发现使用 select(QTextCursor::LineUnderCursor) 或者 BlockUnderCursor 只能删除第一行,第一行删除后还是一个空行。请按照这个思路检查代码:在 append 之后,用 QTextCursor(document) 定位到 Start,然后循环执行 movePosition(QTextCursor::Down, QTextCursor::KeepAnchor),次数等于 rowCount - maxRowNumber,最后 removeSelectedText()。帮我确认这样做是否正确,并给出完整的函数实现。”
把问题描述得越具体,Codex 越不容易跑偏。它不需要你告诉它什么是 block,什么是 QTextEdit,你只需要把现象、已有思路、卡住的点说清楚。
3.2 Codex 给出的关键修正
Codex 看完后没有直接甩代码,而是先指出一个关键点:不能在append()之前用旧的textCursor()去删行,因为此时光标可能已经被滚动条和用户操作带到了末尾,选区起点不在文档开头。应该基于QTextDocument新建一个独立的光标对象,这样删除动作不受界面上焦点位置影响。
然后它给出了修正后的代码,核心结构是:先追加日志,再取document->blockCount(),如果超限,创建QTextCursor(document)移动到Start,用Down + KeepAnchor逐行向下选中需要删除的行,最后removeSelectedText()。这个思路和原始方案一致,但放弃了select(LineUnderCursor)的写法,因为那一类选择方式不包含换行符。
下面是 Codex 生成的完整追加函数:
void MainWindow::textEditAppend(const QString &text) { // 1. 追加日志,光标先移到末尾 QTextCursor cursor = ui->textEdit->textCursor(); cursor.movePosition(QTextCursor::End); ui->textEdit->setTextCursor(cursor); ui->textEdit->append(QDateTime::currentDateTime() .toString("yyyy/MM/dd hh:mm:ss >> ") + text); // 2. 获取文档并检查行数 QTextDocument *document = ui->textEdit->document(); const int rowCount = document->blockCount(); const int maxRowNumber = 5000; // 3. 超过上限则删除最早的行 if (rowCount > maxRowNumber) { QTextCursor deleteCursor(document); deleteCursor.movePosition(QTextCursor::Start); // 向下选中要删除的行数,KeepAnchor 使得选区覆盖换行符 for (int i = 0; i < rowCount - maxRowNumber; ++i) { deleteCursor.movePosition(QTextCursor::Down, QTextCursor::KeepAnchor); } deleteCursor.removeSelectedText(); } }这段代码跟原始思路的最大区别是:删行光标是从document直接构造的,而不是从ui->textEdit->textCursor()复制来的。QTextCursor(document)默认位置在文档开头,省掉了movePosition(Start)这一步,语义也更清楚。后面那个 for 循环的次数是rowCount - maxRowNumber,这意味着只删超出上限的部分,保留最近maxRowNumber行。
4. 为什么不能只用select(LineUnderCursor)删行
4.1 换行符才是删不干净的真凶
Codex 在代码里特意用了Down + KeepAnchor,而不是select(QTextCursor::LineUnderCursor),原因隐藏在 QTextCursor 的选择规则里。LineUnderCursor和BlockUnderCursor只把光标所在块的文本选中,不会把块尾的换行符纳入选区。于是第一次删除时,第一行的文字被拿掉,但段落的结束符还在,QTextEdit 里就留下一个空段落。下一次再执行同样的逻辑,空段落又变成“第一行”,继续删它,后面的内容始终一动不动。
如果用Down + KeepAnchor,情况就不一样。每次向下移动一行时,KeepAnchor会把移动过程中经过的换行符一并纳入选中范围。比如要删 3 行,选区就会从文档开头覆盖到第三行尾部,包含行尾的换行符,这样removeSelectedText()才能真正把这三行以及它们之间的分隔符全部移除。
这就是原始文章里提到的注意事项:对删除“整行”来说,单纯按照“行”去选择文本是不行的,必须把换行符也选进来。Codex 理解了这个需求后,没有选择更复杂的正则匹配,而是用最原始的movePosition循环,反而稳定。
4.2 边界情况:最后一行没有换行符会怎样
还有一个容易被忽略的点:如果日志最后一行后面没有换行符,blockCount()统计行数时会怎样?QTextDocument 的 block 是以换行符为分隔的,文本末尾没有换行符时,最后一段也算一个 block。这意味着你看到 5000 行日志,blockCount()可能是 5001 甚至更多,因为初始空文档也有一个空 block。
所以maxRowNumber的具体数值要留一点余量。我在程序里把maxRowNumber设为 5000,实际运行时 blockCount 会稳定在 5000 多一点,不再无限上涨。Codex 也提醒过:如果担心最后保留的行数比预期少一行,可以在删除后手动清理文档开头可能残留的空 block,方法是把光标移到Start,用Down + KeepAnchor选中一个空行再删除一次。但对于日志窗口来说,少一行多一行无所谓,我就没做这一步。
5. 验证与排障:从跑满内存到稳定运行
5.1 验证步骤:先压测,再回归
代码改完之后,我没有直接让它跑三小时验证。先用一个临时小循环模拟高频日志:
- 把
maxRowNumber临时改成 200,方便观察效果。 - 写一个定时器,每 20 毫秒调用一次
textEditAppend(),内容随意,总共调用 2000 次。 - 每 200 次之后,记录
ui->textEdit->document()->blockCount()和进程内存。 - 如果
blockCount()稳定在 201 左右,内存没有持续增长,说明删除逻辑生效。 - 再把
maxRowNumber改回 5000,接上真实日志源,跑一晚上。
关键看一个指标:blockCount()是否被限制在上限附近。如果它一直往上走,说明if (rowCount > maxRowNumber)这个分支根本没进去,或者是删行代码写错了地方。我在测试时故意把removeSelectedText()注释掉,结果内存照涨,验证了删除动作是必须的。
5.2 排障:Codex 调用不通时先查这三处
在让 Codex 跑代码之前,模型通道本身也可能出问题。我这里整理了几个实际遇到过的坑:
- 环境变量没生效:在终端里执行
echo $TAOTOKEN_API_KEY,如果输出为空,说明export没有执行,或者你新开了一个终端导致变量丢失。Codex 会报 401 认证失败。 - Base URL 末尾被加了
/v1:Codex 有些版本会自动拼/v1,而 TaoToken 的 Base URL 是https://taotoken.net/api,末尾没有/v1。如果报 404,先去掉配置文件里的/v1再试。 - 模型 ID 写错:Codex 报 model not found 时,去 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=qtextedit_codex 的模型广场复制最新 ID,不要凭记忆输入。
还有一次我遇到的是端口代理冲突,但那属于本机网络环境问题,和 TaoToken 无关。换了一个干净的无线网络后就正常了。总之,要先确认 Key 能用、Base URL 能连通,再让 Codex 去改 QTextEdit 的代码,不要同时排查两个问题。
6. 收尾:把这次改动固化成长期方案
日志窗口的 QTextEdit 内存崩溃问题,本质是“只增加不删除”造成的。加上行数上限和 QTextCursor 删除逻辑后,程序连续跑了一周多,内存占用稳定在几十 MB,再没出现过日志区把进程拖垮的情况。Codex 在这个过程里帮我确认了Down + KeepAnchor的选法,比我自己记着的LineUnderCursor靠谱很多。
如果你也准备让 Codex 用这种思路改代码,建议先去 TaoToken 模型对话 发一条测试消息,确认这把 Key 能正常返回结果;日常写代码量大的话,可以看一眼 Coding Plan 和 控制台 API Keys 的用量记录。Key 创建后从控制台复制,模型 ID 始终以模型广场为准,不要用网上文章里的旧 ID。这样排查代码时,你只需要关心 QTextCursor 选中的是不是真的“整行”,而不是在接口报错上浪费时间。