在之前的一篇文章中,我们成功搭建了一个基于 Blazor 的本地聊天应用,它依赖于 Ollama 运行 Llama 3.2 模型进行大型语言模型 (LLM) 推理,使用 all-minilm 进行嵌入 (Embedding),并利用 Qdrant 作为向量数据库。所有这些资源都在不同的 Docker 容器中运行,并由 Aspire 进行启动和管理。虽然整个过程顺利,但本质上只是配置和部署,并没有涉及到任何实质性的代码编写。本文将带领大家对现有应用进行改造,彻底移除 Ollama、Llama 3.2、all-minilm 和 Qdrant,甚至无需 Docker Desktop。我们将使用 Foundry Local 直接连接到支持的 LLM,实现更简洁、更高效的本地聊天体验。
移除 Docker 依赖:简化架构
第一步,我们要大刀阔斧地移除对 Ollama、Llama 3.2、all-minilm 和 Qdrant 的依赖,这意味着摆脱复杂的 Docker 容器管理。打开 Visual Studio 2022,找到 AppHost 项目中的 Program.cs
文件。原始代码负责将所有资源连接起来运行项目,我们需要将其简化。
// 原始代码
var builder = DistributedApplication.CreateBuilder(args);
var ollama = builder.AddOllama("ollama")
.WithDataVolume();
var chat = ollama.AddModel("chat", "llama3.2");
var embeddings = ollama.AddModel("embeddings", "all-minilm");
var vectorDB = builder.AddQdrant("vectordb")
.WithDataVolume()
.WithLifetime(ContainerLifetime.Persistent);
var webApp = builder.AddProject<Projects.FoundryLocalChatApp_Web>("aichatweb-app");
webApp
.WithReference(chat)
.WithReference(embeddings)
.WaitFor(chat)
.WaitFor(embeddings);
webApp
.WithReference(vectorDB)
.WaitFor(vectorDB);
builder.Build().Run();
移除对 Ollama、Llama 3.2 和 Qdrant 的引用后,代码将变得非常简洁:
// 简化后的代码
var builder = DistributedApplication.CreateBuilder(args);
var webApp = builder.AddProject<Projects.FoundryLocalChatApp_Web>("aichatweb-app");
builder.Build().Run();
只需这两行代码,就能启动 Blazor Web 应用,无需关心底层的容器编排。
引入 Aspire.Azure.AI.OpenAI 包:连接 Foundry Local
现在,我们要连接 Foundry Local,这需要引入相应的 NuGet 包。右键单击 Web 项目,选择“编辑项目文件”,将 <PackageReference>
部分替换为以下内容:
<PackageReference Include="Aspire.Azure.AI.OpenAI" Version="9.3.0-preview.1.25265.20" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.5.0-preview.1.25265.7" />
<PackageReference Include="Microsoft.Extensions.AI" Version="9.5.0" />
<PackageReference Include="Microsoft.SemanticKernel.Core" Version="1.53.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
这些包提供了与 Azure OpenAI 服务以及 Microsoft Semantic Kernel 集成的能力,虽然我们使用的是 Foundry Local,但它们提供了通用的 OpenAI 客户端,可以方便地连接到任何兼容 OpenAI API 的服务。
配置 OpenAI 客户端:指向 Foundry Local
接下来,修改 Web 项目中的 Program.cs
文件,移除原有的 Ollama 和 Qdrant 客户端配置,并替换为 Foundry Local 的配置。
// 原始代码
builder.AddOllamaApiClient("chat")
.AddChatClient()
.UseFunctionInvocation()
.UseOpenTelemetry(configure: c =>
c.EnableSensitiveData = builder.Environment.IsDevelopment());
builder.AddOllamaApiClient("embeddings")
.AddEmbeddingGenerator();
builder.AddQdrantClient("vectordb");
builder.Services.AddQdrantCollection<Guid, IngestedChunk>("data-foundrylocalchatapp-chunks");
builder.Services.AddQdrantCollection<Guid, IngestedDocument>("data-foundrylocalchatapp-documents");
builder.Services.AddScoped<DataIngestor>();
builder.Services.AddSingleton<SemanticSearch>();
替换为以下代码:
// 修改后的代码
var foundryLocal = builder.AddOpenAIClient(
connectionName: "foundryLocal",
configureSettings: options =>
{
options.Endpoint = new Uri("http://localhost:5273/v1");
});
foundryLocal.AddChatClient()
.UseFunctionInvocation()
.UseOpenTelemetry(configure: c =>
c.EnableSensitiveData = builder.Environment.IsDevelopment());
这里关键的一步是将 options.Endpoint
设置为 Foundry Local 的地址。运行 foundry service start
命令可以获取正确的 Endpoint。请务必包含 /v1
后缀。
移除数据导入相关代码:简化流程
由于我们不再需要进行本地文档的向量化和向量数据库的存储,因此可以移除数据导入相关的代码。删除以下代码行:
// 移除数据导入
// By default, we ingest PDF files from the /wwwroot/Data directory. You can ingest from
// other sources by implementing IIngestionSource.
// Important: ensure that any content you ingest is trusted, as it may be reflected back
// to users or could be a source of prompt injection risk.
await DataIngestor.IngestDataAsync(
app.Services,
new PDFDirectorySource(Path.Combine(builder.Environment.WebRootPath, "Data")));
同时,在解决方案资源管理器中,将 Services
文件夹从项目中排除。并从 _Imports.razor
文件中删除 @using FoundryLocalChatApp.Web.Services
语句。
配置 appsettings.json:指定模型
为了让应用知道使用哪个 LLM,我们需要在 appsettings.json
文件中添加相应的配置:
"Aspire": {
"OpenAI": {
"Key": "none",
"Endpoint": "http://localhost:5273/v1",
"Deployment": "Phi-4-mini-instruct-generic-cpu"
}
}
将 Deployment
的值更改为你在 Foundry Local 中运行的模型。注意,Key
字段设置为 "none"
,因为我们不需要 API 密钥来连接 Foundry Local。
修改 Blazor 组件:精简 UI
接下来,我们要修改 Blazor 组件,简化 UI 并调整交互逻辑。
-
Chat.razor:
-
删除
@inject SemanticSearch Search
。 -
将
NoMessagesContent
元素替换为:<NoMessagesContent> <div>Ask me anything.</div> </NoMessagesContent>
-
删除
<SurveyPrompt />
元素。 -
将
SystemPrompt
变量声明替换为:private const string SystemPrompt = @" You are an assistant who answers questions. Use only simple markdown to format your responses. ";
-
声明一个新的变量
CONTEXT_LENGTH
:private const int CONTEXT_LENGTH = 128 * 1024;
-
将
OnInitialized
方法替换为:protected override void OnInitialized() { messages.Add(new(ChatRole.System, SystemPrompt)); chatOptions.MaxOutputTokens = CONTEXT_LENGTH; chatOptions.Temperature = 0.7f; }
-
将
AddUserMessageAsync
方法替换为:private async Task AddUserMessageAsync(ChatMessage userMessage) { CancelAnyCurrentResponse();
// Add the user message to the conversation messages.Add(userMessage); chatSuggestions?.Clear(); await chatInput!.FocusAsync(); // Stream and display a new response from the IChatClient var responseText = new TextContent(""); currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); StateHasChanged(); currentResponseCancellation = new(); await foreach (var update in ChatClient.GetStreamingResponseAsync([.. messages], chatOptions, currentResponseCancellation.Token)) { messages.AddMessages(update, filter: c => c is not TextContent); responseText.Text += update.Text; ChatMessageItem.NotifyChanged(currentResponseMessage); StateHasChanged(); // Update the UI with the new response text } // Store the final response in the conversation, and begin getting suggestions messages.Add(currentResponseMessage!); await chatSuggestions?.Update(messages); currentResponseMessage = null;
}
-
删除
SearchAsync
方法。
-
-
ChatCitation.razor:
- 将此文件从项目中排除,因为它不再被引用。
-
ChatMessageItem.razor:
- 删除所有与
citation
相关的代码块。 - 删除
CitationRegex
变量声明。 - 删除
ParseCitations
方法。
- 删除所有与
-
ChatSuggestions.razor:
-
将
Prompt
变量声明修改为:private static string Prompt = @" Suggest up to 3 follow-up questions that I could ask you to help me complete my task. Each suggestion must be a complete sentence, maximum 6 words. Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, for example 'How do I do that?' or 'Explain ...'. If there are no suggestions, reply with an empty list. Respond ONLY with a valid JSON array of strings, e.g. [""Question 1"", ""Question 2""]. Do not include any explanation or formatting.";
-
将
Update
方法替换为:public async Task Update(IReadOnlyList<ChatMessage> messages) { // Runs in the background and handles its own cancellation/errors await UpdateSuggestionsAsync(messages); }
-
将
UpdateSuggestionsAsync
方法替换为:private async Task UpdateSuggestionsAsync(IReadOnlyList<ChatMessage> messages) { cancellation?.Cancel(); cancellation = new CancellationTokenSource(); try { var response = await ChatClient.GetResponseAsync<string[]>( [.. ReduceMessages(messages), new(ChatRole.User, Prompt)], cancellationToken: cancellation.Token);
string cleanedText = response.Text.Trim().Replace("\"", string.Empty).Replace("[", string.Empty).Replace("]", string.Empty).Trim();
}if (string.IsNullOrEmpty(cleanedText)) { suggestions = null; } else { suggestions = cleanedText.Split(','); } StateHasChanged();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await DispatchExceptionAsync(ex);
}
-
构建并运行应用:体验 Foundry Local 的魅力
完成以上步骤后,构建并运行应用。你将会看到 Aspire 资源界面上只有一个资源在运行:aichatweb-app
。点击链接打开 Blazor 应用,输入问题,即可开始与 Foundry Local 提供的 LLM 进行对话。
总结与展望
通过本文的改造,我们成功地将 Blazor 应用从依赖于 Ollama 和 Qdrant 的复杂架构,简化为直接连接 Foundry Local 的轻量级应用。这种方式不仅降低了部署和维护的复杂度,也提高了应用的性能和响应速度。更重要的是,它为我们提供了更大的灵活性,可以方便地切换不同的 LLM 模型,并根据实际需求进行定制。
未来,我们可以在此基础上进行更多的扩展和改进,例如:
- 支持多个聊天会话。
- 将聊天记录持久化存储。
- 重新启用文档聊天功能,并支持通用聊天和文档问答的混合模式。
- 根据自己的设计风格,自定义 UI 界面。
通过这些努力,我们可以打造一个更加强大、更加个性化的本地聊天应用,充分发挥 Foundry Local 和 Blazor 的优势,为我们的工作和生活带来更多便利。 摆脱了对特定 LLM 的依赖,我们可以自由地选择适合自己需求的模型,并充分利用 Foundry Local 提供的强大功能。