使用 .NET 和 Avalonia 构建高性能 Agent 桌面客户端

本文基于项目源码、示例界面与测试记录,由 AI 辅助整理。

一个编程 Agent 客户端,要同时处理模型流式输出、工具调用、命令执行、文件差异和越来越长的会话。模型回答得再快,如果界面不断重排、滚动卡顿,或者闲置时还在持续占用 CPU,使用体验依然会打折。

我们正在用 .NET、Avalonia 和 AtomUI 构建 TKCode,并围绕这些问题开发了独立的流式 Markdown 组件库 StreamMark。TKCode 提供本机 Claude Code、Codex 和内置 Agent 的统一桌面交互;StreamMark 负责把持续到达的文本,变成可以阅读、选择、复制和审查的原生界面。

这篇文章介绍它们的架构与实现,展示复杂 Markdown 和产品功能,并给出有测量条件的启动、内存与 CPU 数据。当前 TKCode 仍处于早期开发阶段,本文对应 2026 年 10 月 19 日的本地工作树。

图 1:同一轮会话中展示最终回复、表格、代码和文件变更入口。文中产品截图均由真实 Avalonia 组件通过 Headless 与 Skia 渲染,内容来自预置示例项目或测试事件,不代表一次真实付费模型调用。

用自己的订阅登录本机 Claude Code 和 Codex

很多用户已经购买了 Claude 或 ChatGPT 订阅,希望在桌面工作台里继续使用现有的编码工具。TKCode 的外部运行时接入方式是:启动用户本机安装的官方 CLI,由 CLI 处理模型访问和已有登录状态,TKCode 接收它输出的事件并提供界面。

这与内置 Agent 是两条清楚分开的路径:

使用方式 谁执行 Agent 循环 如何认证与计费
内置 Agent TKCode Agent Host 配置模型厂商 API Key 或兼容服务,按对应服务规则计费
Claude Agent 本机 Claude Code 用户在官方 CLI 中登录自己的账号,实际认证方式决定使用订阅或 API 计费
Codex 本机 codex app-server 由 Codex 管理 ChatGPT 登录或 API Key,按对应账号与工作区权限运行

登录留在官方工具中完成

先按官方说明安装 CLI,再在终端中完成登录和状态检查。Claude Code CLI 参考 · Codex 认证

bash 复制代码
# Claude Code
claude auth login
claude auth status

# Codex
codex login
codex login status

TKCode 在后台发现运行时,读取其能力与模型目录。用户随后在模型选择器中选择对应运行时,在同一套输入框、会话列表和工作台中使用它。Claude Code 通过 claude -p 的 stream-json 与控制通道接入;Codex 通过 codex app-server 的 JSON-RPC 接入。TKCode 不自行读取或保存 CLI 的订阅令牌文件。

Codex 的官方认证文档明确区分 ChatGPT 订阅登录与按量计费的 API Key;App Server 文档提供了构建自定义客户端所需的会话、流式事件和审批接口。这里使用的是本机 Codex 的认证路径,并不意味着所有 ChatGPT 功能或任意模型 API 都能消耗同一份订阅额度。OpenAI 官方认证文档 · Codex App Server

官方接入有边界 不应承诺绝对不会封号

Anthropic 当前条款允许在符合商业条款及相关条件的产品中运行未修改的 Claude Code,并允许最终用户用自己的订阅登录该官方二进制。同时,条款禁止第三方收集、保存或中转 Claude.ai 凭据,也限制第三方以自己的应用登录入口代用户转发订阅请求。TKCode 的设计是调用本机官方工具,保留其认证机制,让用户通过官方流程使用自己的账号;部署和分发仍须符合适用条款。Claude Code 法律与合规说明

因此,我们能说明的是具体接入方式和凭据边界,不能承诺任何账号"绝对不会封号"。订阅额度、组织策略、使用行为和厂商规则仍然适用;订阅也不等于无限调用。Claude Code 环境中若设置了 ANTHROPIC_API_KEY,还可能改为 API 计费,使用前应核对认证状态。Claude Pro 与 Max 的使用说明

先让工作台出现 再加载 Agent 能力

TKCode 当前采用 .NET 11 RC2、Avalonia 12.1.3 和 AtomUI 6.2.5,桌面端、Agent Host 与终端 Host 都按 NativeAOT 发布。窗口、会话转录、文件预览和终端采用 Avalonia 原生控件与绘制管线,Markdown 展示不依赖 WebView。

首屏只需要让用户看见项目、输入框和基本操作。模型目录、历史会话和外部运行时探测可以稍后完成。我们删除了原先至少展示 1.8 秒的启动加载页,把歇后语放在创建会话区域上方,作为静态文案;Host 初始化排入较低优先级的界面调度队列,优先处理窗口布局和渲染。

图 2:创建会话界面直接显示。文案无需轮播计时器,也不会因为等待一句话播放完而延迟进入工作台。

打包后的首帧约 383 毫秒

在 Apple M5、32 GiB 内存、macOS 26.6.2 上,对当前 osx-arm64 AOT 发布并打包后的 .app 进行 10 次独立进程测量:

场景 从启动进程到首帧完成信号
使用全新配置目录的第 1 次运行 385.1 ms
随后 9 次运行的中位数 382.8 ms
随后 9 次运行的范围 368.8--388.0 ms

这里的首帧是 Headless 与 Skia 已完成渲染,并确认歇后语和输入框可见,不含 PNG 编码时间。测量没有清空系统缓存,不含 Finder 启动、Gatekeeper 首次检查和 macOS 窗口合成,也不等于模型与全部历史会话已经加载完毕。

这组数据验证了首帧不再受加载页最短展示时间约束。当前发布的桌面端和两个 Host 均为 arm64 原生可执行文件,发布日志中 IL#### 警告为零,发布版无头冒烟检查通过。10 次启动的逐次实测值为 385.1、380.5、388.0、384.1、383.2、368.8、379.9、375.2、382.8 和 383.2 ms。

用独立 Agent Host 保持界面职责清晰

TKCode 把界面与 Agent 执行拆成独立进程。Desktop 处理输入、审批、展示和工作台;Agent Host 管理会话、工具和运行时;终端另有 PTY Host,负责终端会话的生命周期。

内置 Agent 的依赖方向保持单向:

text 复制代码
TKCode.Desktop
    │ JSON-RPC 2.0 / stdio
    ▼
TKCode.AgentHost
    ├── TKCode.Agent.Coding   编码会话、工具、系统提示、持久化
    │       └── TKCode.Agent  Agent 循环、审批与事件
    │               └── TKCode.Ai  统一消息与模型协议适配
    └── TKCode.Runtimes
            ├── Claude Code
            ├── Codex app-server
            └── ACP Agent

底层三个内置 Agent 项目不引用 UI。相同的会话与工具逻辑可以脱离窗口测试,协议错误也不需要靠界面操作才能重现。进程边界让职责和故障处理更明确,但它本身不意味着总内存一定更低,统计资源时仍要把相关进程加起来。

把不同运行时翻译成同一套事件

对桌面端来说,关键事件是文本增量、思考增量、工具开始与结束、审批请求、用量和轮次结束。Claude、Codex 与内置 Agent 的协议差异留在运行时适配层,UI 消费统一的 SessionEvent。

外部运行时仍然负责自己的工具循环和上下文管理,TKCode 不把它们当作单纯的文本接口。适配层保存原生会话 ID,映射中断和恢复能力,并把需要用户决定的操作接入桌面审批。判断完成也依据对应轮次的终止事件,而不是"几秒钟没有文本就算结束"。

图 3:这张架构图本身就是 TKCode 工作台中的 Mermaid Markdown 预览,由原生绘制管线呈现。

会话记录和协议数据同样影响稳定性

会话以只追加的 JSONL 记录保存在本地。系统提示和工具集在会话开始时冻结,后续变化通过追加消息表达;上下文压缩只在两次模型请求之间进行,旧记录仍可用于历史回看。

适配器还必须保留厂商要求原样回放的数据,例如思考签名和 reasoning item。为了统一界面而丢掉这些字段,下一轮请求就可能失效。流中发生错误时,我们通过错误事件结束,保留已经收到的部分内容,避免一轮网络失败把整段回复清空。

高频增量进入 Desktop 后,先排入队列,再合并调度到 UI 线程,减少每个 token 都单独投递一次界面任务的开销。这样的优化必须保留事件顺序,工具调用与结果才能正确对应。

为流式对话开发 StreamMark

静态 Markdown 阅读器通常拿到的是一篇完整文档。Agent 输出则随时可能停在半个单词、未闭合的代码围栏、表格行或公式中间;前面的内容还可能已经有几万字。

我们开发的 StreamMark 重点处理这种持续追加的场景。它建立在 Markdig、Avalonia 等已有组件之上,核心工作是流式文档模型、增量解析与排版、虚拟化会话和原生交互;语法高亮、公式与图表分别集成 TextMate、RaTeX 和 Mermaid 渲染能力。StreamMark 源码

图 4:同一份 Markdown 混合展示中文样式、表格、C# 代码高亮和注意力公式,使用与产品一致的暗色主题。

冻结已稳定的前缀 只处理正在变化的尾部

StreamMark 的流式处理可以概括为:

text 复制代码
文本增量 → 节奏控制 → 只追加源码缓冲 → 后台解析
                                      │
                       已冻结前缀共享 + 可变尾部
                                      ↓
                       不可变快照 → 差异 → 更新块视图

解析器扫描安全的冻结边界,已经稳定的片段在同一代文档中解析一次并共享给后续快照。新内容主要影响尾部;未闭合代码块还可以走快速路径,避免每次追加都调用完整解析器。

解析任务在后台串行合并:如果一次解析尚未结束又收到新文本,后续处理最新状态,减少对中间版本的重复工作。界面层每帧最多读取一次最新快照,再根据稳定的块 key 应用插入、更新和删除。

流结束后仍会进行一次完整解析,修正引用定义、脚注和列表等可能跨越片段的语义。增量策略服务于流式响应速度,最终结果仍要与完整文档的含义一致。

增量处理还要延伸到排版和绘制

只减少解析并不够。文本块按行保存布局与视觉对象,追加内容从发生差异的位置附近重新排版,变化的行才重新录制。代码高亮也按行缓存语法状态,避免每个 token 都重新着色整个代码块。

长会话采用 TranscriptPanel 虚拟化,把 Markdown 顶层块和宿主内容展开成行,只实例化视口附近的行。选择与复制使用独立于控件实例的坐标模型,因此内容滚出视口后,仍能保留跨块选择和复制的语义。单文档 MarkdownView 本身不虚拟化,不能把会话容器的能力误认为所有文档视图都天然具备。历史文本和消息模型仍然占用数据内存,虚拟化主要减少控件、布局和绘制成本。

公式由 RaTeX 原生排版;Mermaid 在代码块闭合后后台渲染,再通过 SVG 与 Skia 进行矢量绘制。重型渲染不需要在每个文本增量到达时重新启动。

丰富展示要与审查操作结合

编程任务中的 Markdown 还包含差异、任务列表和说明细节。用户需要看清哪里变了、工具做了什么,以及验证进行到了哪一步。

图 5:diff 的增删背景、行内差异高亮、任务列表和 details 展开内容。截图中的任务状态是展示样例。

TKCode 把流式回复、工具活动和文件变更接到同一个会话里。Markdown diff 适合解释修改,文件变更面板则提供审查入口,避免用户只看见一句"已完成",却找不到具体改动。

降低内存占用 并让闲置界面停下来

资源优化需要看清常驻对象、图片、字体和定时器。NativeAOT 是部署和启动方案的一部分,不能代替这些工作。

不在启动时预热所有语法

TKCode 的 TextMate 高亮器按语言和当前主题按需加载,避免为尚未打开的语言提前建立完整语法对象。代码先显示,再由后台任务补充高亮,首屏不会为了未来可能用到的能力付出全部成本。

Markdown 图片按显示尺寸解码,并把缩小后的结果保存到磁盘缓存。在默认压缩模式下,使用每逻辑像素一个解码像素的密度;对相同显示区域,2 倍 Retina 的像素存储量可降到四分之一,但这不代表整个应用内存也按同样比例下降。用户查看原图时,仍需承担原图的内存成本。

隐藏的动画也必须停止

一个不可见但仍然运行的转圈动画,可能不断请求重绘。TKCode 的点阵加载控件会检查自身及祖先的有效可见性:离开可视树或被隐藏时停止计时器。首页歇后语采用静态显示,也去掉了额外的轮播唤醒。

我们的目标是让窗口在没有输入、网络增量和活动任务时安静下来。低空闲 CPU 与流式输出时的 CPU 是两项不同指标;解析、图表渲染和实际工具执行期间仍然需要计算资源。

已记录的内存和空闲 CPU 数据

仓库保存的一组 macOS arm64 历史基准如下,记录于 2026 年 10 月 17 日,版本标记为 a9d6070。基准使用隔离配置和模拟模型,等待场景稳定后主动 GC,再统计桌面进程的 footprint;表中的 MiB 按 1024² 字节换算。

场景 稳定 footprint 历史峰值 GC 堆 静置 CPU
空闲窗口 139.0 MiB 266.6 MiB 21.2 MiB 0.0%
Markdown 首次加载 204.6 MiB 312.3 MiB 21.9 MiB 0.0%
Markdown 缓存复用 159.9 MiB 267.5 MiB 23.9 MiB 0.0%
对话结束后 163.9 MiB 285.6 MiB 28.0 MiB 0.0%

这些是此前构建的场景记录,不能替代当前 .NET 11 RC2 版本的同条件内存复测。它们反映的是桌面进程,不包含 Agent Host、PTY Host、Claude Code、Codex 或工具子进程;模拟对话也不代表真实推理负载。

CPU 按进程 CPU 时间增量除以静置窗口时长计算,默认观察 10 秒,再保留一位小数。0.0% 表示该窗口内的结果经四舍五入显示为 0.0%,不代表永远没有 CPU 活动。内存表中主动 GC 后的稳定值和峰值也需要一起看,不能只挑最小值作为所有使用场景的承诺。原始记录来自项目的 artifacts/mem-bench.txt,测量逻辑位于 bench/TKCode.MemoryHarness/Program.cs。

这组结果说明,原生界面可以在包含 Markdown、代码和图片能力的同时,把空闲占用控制在较小范围。进一步评价"极低"或优于某款客户端,需要统一工作负载和完整进程统计后的对比测试。

让 Agent 成为可以日常使用的桌面工作台

TKCode 围绕项目和会话组织任务。创建会话时选择工作目录、模型与模式;进入对话后,输入区停靠到底部,把空间留给输出和操作记录。@ 文件引用、/ 命令与技能、图片附件让上下文输入保持集中。

图 6:会话、代码预览和文件树并排展示。长代码可以横向滚动,工作台也可以展开占据主区域。

内置文件、搜索、命令和任务工具把操作过程变成结构化事件。MCP 接入提供工具、资源和提示词扩展;外部运行时的能力和权限仍按各自协议映射,不能假定所有模式都完全相同。

图 7:模拟事件驱动的真实工具活动界面,展示文件差异、命令输出、子任务统计与后台通知。图中的 token 数和测试输出是界面测试数据,不是性能测量。

命令面板集中提供新建对话、打开设置、切换主题和工作台等入口;中英文和明暗主题共用同一套组件与语义。统一设计 token 也让公式、表格和图表能够跟随产品主题,减少不同渲染区域之间的视觉割裂。

图 8:命令面板通过搜索和键盘导航减少菜单层级。

NativeAOT 发布与可复现验证

TKCode 从项目边界就约束 AOT 兼容性:JSON 使用源生成的 JsonSerializerContext,避免依赖运行时反射和动态代码生成,XAML 与绑定在构建时处理。三类可执行程序分别发布,Host 的 stdout 专门传输 RPC,日志走 stderr,避免把日志文本混进协议流。

发布成功还不是界面验证完成。我们会对发布后的二进制运行无头冒烟检查,覆盖模型加载、模拟对话、Markdown、工具、终端和首帧截图。这能发现开发构建正常、裁剪或 AOT 后资源缺失的问题。

当前 macOS 打包与首帧测量可以复现为:

bash 复制代码
bash tools/package_macos_app.sh osx-arm64 artifacts/startup-bench
python3 tools/startup-bench.py artifacts/startup-bench/TKCode.app

artifacts/startup-bench/TKCode.app/Contents/MacOS/TKCode \
  --smoke --out=artifacts/startup-bench/smoke-frame.png

文章截图也可以从示例项目重新生成,不需要操作真实桌面或使用真实账号请求:

bash 复制代码
mkdir -p docs/articles/images/tkcode
TKCODE_ARTICLE_SCREENSHOTS=1 bash tools/readme-screenshots.sh docs/articles/images/tkcode

TKCODE_SNAPSHOT_DIR="$PWD/docs/articles/images/tkcode" \
  dotnet test tests/TKCode.Desktop.Tests --no-build \
  --filter 'FullyQualifiedName~AgentToolsSnapshotTests'

TKCode 项目与 StreamMark 项目分别维护桌面 Agent 工作流和原生流式 Markdown 能力。接下来需要继续用长会话、大文件、真实 CLI 与多平台负载验证:既看首帧,也看持续运行后的响应、内存和完整进程成本。