在之前的一篇文章中,我们成功搭建了一个基于 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 =&gt; 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 LocalBlazor 的优势,为我们的工作和生活带来更多便利。 摆脱了对特定 LLM 的依赖,我们可以自由地选择适合自己需求的模型,并充分利用 Foundry Local 提供的强大功能。