ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# WPF+Ollama打造完全本地化的AI聊天桌面应用

C# WPF+Ollama打造完全本地化的AI聊天桌面应用 简介这是一套基于WPF与OllamaSharpe构建的本地AI聊天示例项目面向需要集成离线大模型对话能力的C#桌面开发者。项目借助OllamaSharpe启用本地Ollama服务配合Markdig.WPF将模型输出渲染为格式化Markdown并利用Microsoft.Xaml.Behaviors.Wpf解决部分控件的命令绑定功能上支持折叠栏展开/折叠、系统设置与聊天视图切换、新建会话隔离聊天记录、窗体加载时自动载入历史消息关闭时释放资源构造函数也改为无参形式方便设计器直接绑定数据上下文。资源共2000个文件以C#源码662个cs、XAML界面、JSON配置、DLL依赖及PNG图标等为主压缩包仅3.83MB轻量且结构清晰适合学习从零搭建WPF本地LLM交互应用的完整流程。目前已有255人浏览学习可作为C#接入本地AI能力时的参考实现与起步模板。 把大模型聊天功能做成完全本地的桌面应用最近我一直在折腾这件事。最终敲定的方案是 C# WPF 做界面OllamaSharp 跑大模型通信Markdig.WPF 负责 Markdown 渲染整套下来就是一个不依赖云端 API、完全离线、隐私不落地的本地 AI 聊天工具。这篇文章会把这个项目从零到一的完整思路、核心代码、踩坑记录全部摊开讲适合正在考虑用 C# 做 AI 桌面应用、想绕开官方 SDK 自己接 Ollama 的开发者参考。先交代一下背景。我手上有个内部工具需要嵌入一个问答助手数据全是内部资料走云端模型既不安全审核流程也拖沓。于是本地化成了唯一出路。Ollama 是目前把本地模型体验做得最顺的运行时安装快、模型管理简单还自带一个 HTTP API。而 .NET 生态里 OllamaSharp 这个库把 API 包装得很干净配合 WPF 做桌面壳子Markdig.WPF 把模型输出的 Markdown 渲染成带排版的内容三个组件各管一摊配合起来非常顺手。1. 项目概述与技术选型思路1.1 为什么选择本地 AI 方案做本地 AI 聊天最有说服力的理由就三个隐私、成本、可控性。数据不出本机这是云端 API 永远给不了的保障模型跑在本地没有按 token 计费的问题随便聊断网也能用不依赖服务商可用性。对一个桌面工具来说这三点直接决定了产品形态。当然本地方案也有代价。最直观的是需要一台像样的机器显存越大能跑的模型越大。用 Ollama 跑 7B 到 8B 参数的量化模型16GB 内存的机器就能获得可用体验如果有一张 8GB 显存的显卡生成速度会明显上一个台阶。做这个项目之前建议先想清楚自己的硬件基线这决定了后面模型选型。1.2 技术栈选型分析技术栈我重点对比过几个方向。UI 框架当时在 WPF 和 Avalonia 之间犹豫过Avalonia 跨平台确实香但 WPF 在 Windows 生态里资料最多踩坑时能找到的解决方案也最多而且这个工具本来就只跑 Windows所以选了 WPF。MVVM 框架这块项目不大我直接用了 CommunityToolkit.Mvvm轻量、没有历史包袱配合源生成器写 ViewModel 很舒服。OllamaSharp 是沟通 Ollama 服务端的 C# 客户端库封装了聊天、嵌入、模型管理等 API天然支持流式返回这对做聊天界面至关重要。Markdig.WPF 则是 Markdig 的 WPF 封装把模型输出的 Markdown 文本渲染成格式化内容。选它的原因很直接——聊天场景里模型回 Markdown 几乎是常态代码块、列表、加粗标题这些如果纯文本显示阅读体验非常糟糕。这三个库凑齐之后整个链路就是WPF 收集用户输入 → OllamaSharp 发给 Ollama → 流式拿回复 → Markdig.WPF 渲染展示。2. 环境准备与项目初始化2.1 Ollama 安装与模型拉取第一步先把 Ollama 装好。直接去官网下载安装包Windows 版安装完会自动注册成后台服务默认监听 11434 端口。装完之后在命令行验证一下ollama --version ollama listollama list输出空列表是正常的因为还没拉模型。拉模型用ollama pull命令模型名称和 Tag 可以在模型库里查。我这边选的是 qwen2.5:7b中文效果好资源占用适中作为通用对话模型性价比很高。ollama pull qwen2.5:7b注意ollama pull首次要下载几个 GB 的权重文件国内网络慢的话多等一会儿。如果公司网络有限制可以在环境变量里配置代理但这不是本文重点先保证能拉下来就行。拉完模型再用ollama list确认能看到qwen2.5:7b出现在列表里就说明本地模型就绪了。之后用ollama run qwen2.5:7b可以快速在命令行里验证一下模型能不能正常对话这一步提前排除模型本身的问题后面接代码的时候会省很多排查时间。2.2 创建 WPF 项目与 NuGet 依赖打开 Visual Studio 2022创建 WPF 项目目标框架我选的是 .NET 8。项目骨架建好之后NuGet 里装三个包Install-Package OllamaSharp Install-Package Markdig.WPF Install-Package CommunityToolkit.Mvvm装完之后检查一下 csproj确认 TargetFramework 是 net8.0-windows。Markdig.WPF 依赖 Markdig 核心库NuGet 会自动带上不用手动装。这里有个小坑Markdig.WPF 的 XAML 命名空间是xmlns:mdxamclr-namespace:Markdig.WPF;assemblyMarkdig.WPF如果你在工具箱里找不到控件直接手动写 XAML 就行工具箱显示不全不代表引用失败。项目的目录结构我习惯按功能分文件夹Models 放消息模型ViewModels 放聊天主视图模型Views 放 XAML 窗口Services 放 Ollama 封装和 Markdown 渲染辅助类。项目不大没必要上 Prism 那套重型框架分层清晰就够了。3. 核心对话逻辑与流式输出实现3.1 OllamaSharp 客户端封装OllamaSharp 的用法很直白。先创建一个OllamaApiClient指向本地服务地址然后调用ChatAsync方法发起对话。我把它封装成了一个ChatService类避免 ViewModel 里到处都是直接操作 API 的代码。public class ChatService { private readonly OllamaApiClient _client; public ChatService() { _client new OllamaApiClient(http://localhost:11434); } public async Task ChatAsync( string model, ListMessage messages, Actionstring onTokenReceived, CancellationToken cancellationToken) { var request new ChatRequest(model, messages) { Stream true }; await foreach (var response in _client.ChatAsync(request, cancellationToken)) { if (response?.Message?.Content is string content) { onTokenReceived?.Invoke(content); } } } }这里有个关键点Stream true必须显式设置。如果不设置流式ChatAsync就要等模型把整段回复生成完才返回聊天界面会长时间卡在等待中体验极差。设置流式之后模型每生成一个 tokenawait foreach就能拿到一次增量数据这样 UI 才能一个字一个字地冒出来才有正在打字的感觉。Message模型直接复用 OllamaSharp 自带的OllamaSharp.Models.Chat.Message类型它有Roleuser/assistant/system和Content两个核心属性。消息历史我维护在 ViewModel 层的ObservableCollectionChatMessage里每次请求前把历史消息映射成 OllamaSharp 的消息模型传过去。注意OllamaSharp 的 API 版本迭代比较快不同版本的命名空间和参数签名可能有差异。我用的是当前最新版如果 NuGet 拉到的版本较老字段名可能不一样编译报错时优先去 GitHub 仓库看对应版本的 README不要盲改代码。3.2 流式响应与 UI 更新的线程处理流式输出的关键是处理好 UI 线程。await foreach拿到 token 的回调默认在后台线程执行WPF 的控件只能在 UI 线程更新所以必须用Dispatcher切回 UI 线程。我建议不要每个 token 都切一次线程那样开销太大更稳妥的做法是攒一批 token 再刷一次 UI或者用Dispatcher.BeginInvoke按优先级排队更新。ViewModel 核心逻辑大概是这样的[ObservableProperty] private string _currentResponse string.Empty; private async Task SendMessageAsync() { if (string.IsNullOrWhiteSpace(InputText)) return; Messages.Add(new ChatMessage { Role user, Content InputText }); InputText string.Empty; var assistantMessage new ChatMessage { Role assistant, Content string.Empty }; Messages.Add(assistantMessage); var history Messages .Select(m new Message(m.Role user ? MessageRole.User : MessageRole.Assistant, m.Content)) .ToList(); IsBusy true; try { var buffer new StringBuilder(); await _chatService.ChatAsync(ModelName, history, token { buffer.Append(token); Dispatcher.UIThread.InvokeAsync(() { assistantMessage.Content buffer.ToString(); OnPropertyChanged(nameof(CurrentResponse)); ScrollToBottom(); }); }, CancellationToken.None); } finally { IsBusy false; } }注意我用了一个StringBuilder做缓冲每次回调把增量 token 追加进去然后整体赋值给消息的 Content。这样 markdown 渲染始终针对完整文本进行不会出现标签只渲染一半的情况也避免了频繁拼接字符串的性能问题。生成过程中我想支持停止生成操作那就要把CancellationToken暴露出来。在 ViewModel 里保存一个CancellationTokenSource字段点停止按钮时调用Cancel()await foreach会抛OperationCanceledException在 catch 里把会话状态复位即可。这个功能对长回复特别重要不然生成到一半发现答非所问只能干等。4. Markdown 渲染与聊天界面搭建4.1 Markdig.WPF 接入与基本用法Markdig.WPF 提供了一个MarkdownViewer控件绑定一段 Markdown 字符串就能渲染成带格式的富文本。这个控件不是把所有 Markdown 一次性静态渲染而是有一个Markdown依赖属性你绑定字符串内容它内部会走 Markdig 管道解析。XAML 里这样引用Window xmlns:mdxamclr-namespace:Markdig.WPF;assemblyMarkdig.WPF mdxam:MarkdownViewer Markdown{Binding CurrentResponse} / /Window在代码里我建议先构建一个全局的 MarkdownPipeline把扩展组件注册好避免每次渲染都重建管道。Markdig.WPF 自己有一个默认管道但对于代码高亮、表格这些场景最好显式配置public static class MarkdownPipelineFactory { public static MarkdownPipeline Create() { return new MarkdownPipelineBuilder() .UseSupportedExtensions() .UsePipeTables() .UseTaskLists() .Build(); } }UseSupportedExtensions()会启用 Markdig 的官方扩展集合UsePipeTables()让模型输出的表格能正常渲染UseTaskLists()处理 - [ ] 这类任务清单语法。如果你发现某些 Markdown 语法渲染不出来基本都是扩展没注册的问题去 Markdig 文档里找对应的UseXXX方法补上就行。提示Markdig.WPF 对超长内容的渲染性能一般一次性灌入几万字的长文本会有卡顿。如果模型的回复特别长可以考虑分页渲染或者延迟渲染——先显示纯文本预览点击渲染完整内容再走 Markdig。我实际测试中几千字以内的回复实时流式渲染完全流畅可以不用过度优化。4.2 聊天气泡与消息列表实现聊天界面我用一个ListBox承载消息列表每个消息项通过DataTemplate根据 Role 不同呈现不同样式。用户消息右对齐用主色调背景助手消息左对齐用浅灰背景内部嵌一个MarkdownViewer。ListBox ItemsSource{Binding Messages} ScrollViewer.HorizontalScrollBarVisibilityDisabled ListBox.ItemTemplate DataTemplate Grid Grid.ColumnDefinitions ColumnDefinition WidthAuto / ColumnDefinition Width* / /Grid.ColumnDefinitions Border x:NameBubble MaxWidth560 Padding12,8 Margin6,4 CornerRadius8 Background{StaticResource UserBubbleBrush} mdxam:MarkdownViewer Markdown{Binding Content} / /Border /Grid /DataTemplate /ListBox.ItemTemplate /ListBox用户和助手的消息对齐方式用样式触发器根据 Role 切换即可。这里有个布局细节MarkdownViewer内部是FlowDocumentScrollViewer它默认有自己的滚动条。嵌在 ListBox 项里时如果消息很长会出现内层滚动条和外层 ListBox 滚动条打架的问题。解决方式是把MarkdownViewer的滚动功能关掉让它自适应高度由外层 ListBox 统一滚动。mdxam:MarkdownViewer Markdown{Binding Content} FlowDocumentScrollViewer.VerticalScrollBarVisibilityDisabled FlowDocumentScrollViewer.IsToolBarVisibleFalse /设置VerticalScrollBarVisibilityDisabled之后长文本会撑开整个 ListBoxItem 的高度外层滚动条接管滚动。这是聊天类 WPF 应用里非常典型的布局处理不处理的话用户体验会很割裂。自动滚动到底部也要注意时机。我是在每次刷新消息内容后调用MessagesList.ScrollIntoView(lastItem)但流式更新时每来一个 token 都滚动一次会明显卡顿。实际做法是在刷新事件里判断用户是否已经手动滚动了列表如果用户正在回看上面的内容就不要强制拉到底部。private void ScrollToBottomAsync() { if (_isUserScrolling) return; if (MessagesList.Items.Count 0) { var lastItem MessagesList.Items[MessagesList.Items.Count - 1]; MessagesList.ScrollIntoView(lastItem); } }_isUserScrolling在 ListBox 的ScrollChanged事件里维护如果当前偏移量距离底部超过 200 像素就认为用户在看历史内容暂停自动滚动。5. 常见问题与排查实录5.1 连接与模型相关故障这个项目跑起来之后最常遇到的三类问题我做成了一张速查表症状可能原因排查方法请求后立即抛连接异常Ollama 服务未启动命令行执行ollama serve或在托盘图标确认服务状态返回 404 模型不存在模型名拼写错误或未拉取ollama list查看实际模型名注意版本 Tag 要写全首次回复极慢模型权重首次加载或内存换页观察任务管理器内存/显存占用预热模型或换更小模型对话过程中内存持续上涨消息历史无限累积加历史消息截断逻辑只保留最近 N 轮上下文连接异常最好排查因为 Ollama 本地服务如果没起来new OllamaApiClient(http://localhost:11434)本身不会报错直到调用ChatAsync才抛HttpRequestException。所以我在ChatService里加了一个PingAsync方法应用启动时先调一次ollama.ListModelsAsync()检查服务是否可用不可用就在界面上提示用户启动 Ollama而不是等到发消息才报错。模型名的问题也很容易踩。OllamaSharp 的ChatRequest里模型名必须是ollama list输出里的完整名称比如qwen2.5:7b。如果你只写qwen2.5Ollama 会尝试解析默认 Tag有时能对上有时对不上最好严格按列表里的名字填。我在设置界面做成了下拉框从ListModelsAsync动态拉取模型列表从源头上杜绝拼写错误。长对话内存膨胀是容易被忽视的问题。Ollama 的上下文窗口是有限的你传的历史消息越多模型处理越慢内存占用也越高。我的方案是维护一个最大历史轮数超过 10 轮就把最早的对话从请求上下文里剔除只保留最近的消息。这个截断只影响请求上下文界面上的消息列表保持完整用户无感知。5.2 界面渲染与线程问题WPF 做聊天窗口线程和渲染问题是重灾区。最常见的异常是InvalidOperationException: The calling thread cannot access this object because a different thread owns it。原因就是流式回调在后台线程直接改 UI 绑定属性没走 Dispatcher。前面给的代码里用Dispatcher.UIThread.InvokeAsync就是为了解决这个问题。注意 CommunityToolkit.Mvvm 里如果你使用[ObservableProperty]源生成器生成属性它内部不会自动帮你切线程所以这个 Dispatcher 调用不能省。还有一个容易被忽略的问题MarkdownViewer在流式更新时如果绑定属性变化太快渲染引擎会频繁重建文档对象造成 GC 压力。实测下来当回复很长时界面的文字会越刷新越卡。我后来把 UI 刷新频率做了节流——用DispatcherTimer以 50ms 的间隔统一刷新而不是每个 token 到达就立刻刷新卡顿感明显缓解。最后说一个坑。我在做停止生成功能时发现CancellationTokenSource.Cancel()之后await foreach会抛异常但底层 HTTP 连接并不会立即断开Ollama 还是会继续生成一段时间。这个问题在 OllamaSharp 的 GitHub 仓库有讨论目前比较可靠的方案是取消后手动调用释放连接或者在取消逻辑里先发一个Abort请求给 Ollama 的生成接口。如果你的用户频繁点停止不处理的话会积累后台任务。我目前的折中方案是停止后把当前生成任务的引用置空新对话启动前Dispose掉上一个请求实际使用中效果还可以。这套方案跑通之后我最大的体会是本地 AI不等于低配 AI。选择合适尺寸的模型、做好流式渲染和上下文管理一个小型桌面工具完全能获得流畅、可用且隐私安全的 AI 交互体验。如果你后续想扩展可以在这个基础上加嵌入向量做本地知识库或者通过 Ollama 的 Modelfile 定制系统提示词来限定对话风格这些都是很自然的延伸方向。把链路跑通的方式就是先做最小闭环——一行 Markdown 的回复能流畅渲染出来整个架构的骨架就算立住了。本文还有配套的精品资源点击获取
返回列表