Grok 4.20 一发出来,我身边做后端的同事就在问:C# 能不能接?怎么接?说实话,能问出这个问题的人,多半不只是想图个新鲜,而是想把它真正用进自己的业务里。我在 .NET 8 上做 AI 接入已经有大半年,从最开始的控制台 Demo 到现在线上的搜索服务,中间踩过不少坑,也总结了一套还算可靠的做法。这篇文章就把接 Grok 4.20 做 AI 搜索服务的完整经验整理出来,包括架构设计、核心代码、可靠性方案,以及我实际趟过的雷。
内容偏实战,适合已经会用 C# 的开发者,哪怕你之前完全没碰过大模型 API,照着做也能跑通。如果你正在做一个“用户输入一句话,系统返回一段带引用答案”的功能,这篇会对你特别有用。
1. 整体思路:AI 搜索服务到底要解决什么问题
1.1 先想清楚:你要的是“聊天”还是“搜索”
很多人一上来就在控制台里调 Grok 4.20,发一条消息拿到返回,然后就觉得完事了。但真正的 AI 搜索服务不是这样的。
用户输入的是自然语言,比如“帮我找一下上个月项目周报的模板”,你需要的不是模型直接给你一段泛泛而谈的话,而是让系统去真实检索出可用的链接、文件或者条目,再基于这些检索结果整理成答案,并且标清楚引用来源。
所以整个链路通常是这样的:用户提问 -> 把问题拆成适合检索的关键词 -> 调用搜索接口 -> 把搜索结果作为上下文喂给模型 -> 模型生成带引用的回答。这个链路里,Grok 4.20 承担的是两部分工作:一是把用户问题拆解成检索意图,二是做最后的答案总结。只把它当成一个“聊天接口”来用,后面一定会出问题。
1.2 为什么选 C# / .NET 8 而不是其他方案
说实话,接大模型用 Python 的库确实最方便,但如果你是做后端服务的,C# 一点不差。.NET 8 是长期支持版本,自带IHttpClientFactory、依赖注入、ILogger、配置系统,这些是做线上 AI 服务的基础设施,每一件都能直接用。
选 Grok 4.20 主要有几个原因:上下文窗口够长,搜索结果动辄几千字塞进去不会被截断;中文表达自然;JSON 输出和工具调用在实测里比较稳。而且现在它的接口形态基本和主流 Chat Completions 一致,C# 这边用HttpClient发 POST 请求就行,不需要引入什么特殊 SDK。
还有一点不得不提:C# 的类型安全在做这种 JSON 交互时真的香。你定义一个 record,反序列化出问题编译期就能发现很多低级错误,不用等到线上看日志。
1.3 “高可靠”落到工程上,具体是几件事
模型 API 和普通 HTTP 接口不一样,它有两个很烦人的特点:慢,并且不稳定。一次请求可能 1 秒返回,也可能 30 秒才返回;服务方限流、负载高、网络抖动,任何一个环节出错,用户看到的就是“转圈圈”。
高可靠至少要覆盖下面几层:
- 超时控制:模型再慢也不能把后端线程拖死。
- 重试退避:遇到瞬时错误能自动恢复,而不是直接失败。
- 缓存:同一句提问不重复调用模型,省钱也降延迟。
- 限流:防止并发突然暴涨把 API 配额打爆。
- 熔断降级:模型连续失败时自动暂停调用,并返回备用结果。
- 可观测性:每次调用耗时、Token 用量、是否命中缓存,都得有日志。
这六件事缺一不可。下面的内容会逐一展开。
2. 接入前置:协议形态、结构化输出与密钥管理
2.1 协议不复杂,但别把“聊天”和“搜索”搞混
Grok 4.20 的 HTTP 接口格式和现在主流的大模型接口差不多,核心是构造一个messages数组,里面放system、user、assistant角色消息,POST 到一个类似/chat/completions的端点,最后从响应里的choices[0].message.content取结果。
在搜索场景里,messages不是你一句我一句的闲聊。你要做的是把“系统指令”“用户问题”“检索结果”三段内容拼装好之后一次性发过去。也就是说,同一个模型调用,输入里既包含用户的问题,也包含从搜索引擎拿回来的候选片段。模型的任务不是凭空回答,而是根据给定的片段做总结。
这里有个细节:搜索摘要是带格式的,你可以把每条结果编号,比如[1] 标题... URL...,然后要求模型在最终答案里用[1]这类编号标注引用。实测下来,给结果编号是压制“引用幻觉”最有效的一招。
2.2 强制模型输出 JSON:三种手段和选型
搜索服务需要模型返回结构化内容,比如“检索关键词列表”或者“最终答案 + 引用列表”。如果让模型自由发挥,你后续解析字符串会痛不欲生。
我试过三种办法:
第一种,在 system 提示词里写“必须以 JSON 输出”。这个不稳定,模型偶尔会在 JSON 外面包一层 Markdown 代码块,反序列化直接报错。
第二种,设置response_format为{ "type": "json_object" }。这个比第一种稳,但有些接口只支持这种模式,不允许同时传工具,限制比较大。
第三种,注册一个工具函数,让模型必须通过函数调用来返回内容。这是最可控的方式。模型会先返回一个tool_calls列表,里面的function.arguments就是符合你 JSON Schema 的参数。你可以定义一个这样的工具:
{ "type": "function", "function": { "name": "submit_search_answer", "description": "提交搜索答案及引用来源", "parameters": { "type": "object", "properties": { "answer": { "type": "string", "description": "针对用户问题的最终答案" }, "citations": { "type": "array", "items": { "type": "object", "properties": { "title": { "type": "string" }, "url": { "type": "string" } }, "required": ["title", "url"] } } }, "required": ["answer", "citations"] } } }用工具调用的时候,最容易踩的坑是:模型返回的message.content是空的,答案在message.tool_calls[0].function.arguments里。很多人下意识只读content,结果拿到空白。我建议封装一个GrokClient,把工具调用的解析逻辑统一收口,避免业务代码被这些细节污染。
2.3 密钥管理:第一道安全线
把 API Key 写在代码里或者提交到 Git 仓库,是我见过最多的低级错误。密钥一旦泄露,损失不仅是钱的问题,还可能影响整个账号的可用性。
正确做法是环境变量或者dotnet user-secrets。本地开发时用 user-secrets,部署时用环境变量。先初始化:
dotnet user-secrets init dotnet user-secrets set "Grok:ApiKey" "你的密钥"然后在配置系统里建一个GrokOptions类,绑定相关配置。你的代码里永远不要出现真实密钥。
还有一件事容易被忽略:日志脱敏。如果你加了LoggingDelegatingHandler来记录请求和响应,一定要把Authorization头里的内容替换成***。别等出了事故再后悔。
3. 从零搭建:一个可用的 .NET 8 接入示例
3.1 创建项目与基础配置
用一个 Web API 项目承载这个服务,因为它天然支持依赖注入和后台任务,后面接健康检查、监控也方便。
dotnet new webapi -n AiSearchDemo然后添加必要的包,我一般会装这几个:
dotnet add package Microsoft.Extensions.Http.Resilience dotnet add package Microsoft.Extensions.Caching.Memory在Program.cs里注册HttpClient,把一个具体的客户端类型绑定到IHttpClientFactory上:
builder.Services.AddOptions<GrokOptions>() .Bind(builder.Configuration.GetSection("Grok")) .ValidateDataAnnotations() .ValidateOnStart(); builder.Services.AddHttpClient<IGrokClient, GrokClient>((sp, client) => { var options = sp.GetRequiredService<IOptions<GrokOptions>>().Value; client.BaseAddress = new Uri(options.BaseUrl); client.Timeout = TimeSpan.FromSeconds(30); }) .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3), PooledConnectionLifetime = TimeSpan.FromMinutes(2) }) .AddStandardResilienceHandler();解释一下几个关键选择。HttpClient.Timeout是整个请求的总超时,30 秒对 Grok 4.20 这种模型接口是合理的,但业务方如果要求更严格的响应时间,你要放到 10 到 15 秒左右。SocketsHttpHandler.ConnectTimeout管的是 TCP 建连阶段,不能让它占掉总超时的大头。PooledConnectionLifetime设成 2 分钟,是为了让连接定期轮换,避免 DNS 更新后仍然连到旧地址。
AddStandardResilienceHandler()是官方提供的标准策略组合,包含重试、超时、熔断,基本开箱即用。但我后面还是会讲手动实现重试和熔断,因为你在生产环境里总有一些定制需求,比如针对 429 的状态码做特殊处理。
3.2 定义请求和响应模型
推荐用record定义消息模型,简洁且方便做不可变处理。注意接口返回的字段一般都是 camelCase,所以要么用[JsonPropertyName],要么全局配置 camelCase 命名策略。
public sealed record ChatMessage( string Role, string Content); public sealed record ChatTool( string Type, ChatFunction Function); public sealed record ChatFunction( string Name, string Description, object Parameters); public sealed class ChatRequest { [JsonPropertyName("model")] public string Model { get; set; } = string.Empty; [JsonPropertyName("messages")] public List<ChatMessage> Messages { get; set; } = new(); [JsonPropertyName("temperature")] public double? Temperature { get; set; } [JsonPropertyName("max_tokens")] public int? MaxTokens { get; set; } [JsonPropertyName("tools")] public List<ChatTool>? Tools { get; set; } }响应模型需要特别关注choices和usage两个字段。监听 Token 用量对成本控制很重要,很多人忽略这一点,到了月底账单才傻眼。
public class ChatResponse { [JsonPropertyName("choices")] public List<Choice>? Choices { get; set; } [JsonPropertyName("usage")] public Usage? Usage { get; set; } } public class Choice { [JsonPropertyName("message")] public ResponseMessage? Message { get; set; } [JsonPropertyName("finish_reason")] public string? FinishReason { get; set; } } public class ResponseMessage { [JsonPropertyName("content")] public string? Content { get; set; } [JsonPropertyName("tool_calls")] public List<ToolCall>? ToolCalls { get; set; } } public class ToolCall { [JsonPropertyName("id")] public string? Id { get; set; } [JsonPropertyName("function")] public FunctionCall? Function { get; set; } } public class FunctionCall { [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("arguments")] public string? Arguments { get; set; } }3.3 封装 Grok 客户端:别裸用 HttpClient
搜索服务里会有多处调用模型:生成检索词、生成最终答案、偶尔还要重试解析。如果到处直接操作HttpClient,后面改一个超时或加一层日志就要改很多地方。
我习惯封装一个GrokClient,只暴露很少的方法:
public interface IGrokClient { Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct); } public class GrokClient : IGrokClient { private readonly HttpClient _httpClient; private readonly ILogger<GrokClient> _logger; public GrokClient(HttpClient httpClient, ILogger<GrokClient> logger) { _httpClient = httpClient; _logger = logger; } public async Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct) { var stopwatch = Stopwatch.StartNew(); try { using var response = await _httpClient.PostAsJsonAsync("/chat/completions", request, ct); if (!response.IsSuccessStatusCode) { var detail = await response.Content.ReadAsStringAsync(ct); throw new GrokApiException(response.StatusCode, detail); } var body = await response.Content.ReadFromJsonAsync<ChatResponse>(ct); stopwatch.Stop(); _logger.LogInformation( "Grok call finished in {ElapsedMs} ms, prompt_tokens={PromptTokens}, completion_tokens={CompletionTokens}", stopwatch.ElapsedMilliseconds, body?.Usage?.PromptTokens ?? 0, body?.Usage?.CompletionTokens ?? 0); return body!; } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { throw new TimeoutException("Grok request timeout"); } } }这段代码有几个细节值得注意。
超时抛出的TaskCanceledException和外部主动取消的OperationCanceledException要区分开。如果用户没有取消,但请求超时了,应该转成一个明确的业务异常,方便上层重试策略捕获。如果在catch里没有做区分,你会把“用户主动取消”也当成“超时”,重试逻辑会错误地再发一次请求。
对于非 200 状态码,我用自定义的GrokApiException包装,里面保留状态码和原始响应内容。这样上层可以根据状态码区分是限流、参数错误还是服务不可用。
3.4 搜索编排器:两条模型调用串成完整链路
下面是最核心的编排逻辑。先定义一个抽象的搜索接口,方便你替换成自研搜索引擎或者第三方搜索 API:
public interface ISearchProvider { Task<IReadOnlyList<SearchResult>> SearchAsync(string keyword, CancellationToken ct); } public sealed record SearchResult(string Title, string Url, string Snippet);然后实现一个编排器,流程分三步。
第一步,让模型生成检索关键词。你不要让用户的原话直接去搜索,因为自然语言和搜索引擎的关键词语法往往不匹配。
public async Task<List<string>> BuildSearchQueriesAsync(string userQuery, CancellationToken ct) { var request = new ChatRequest { Model = _options.Model, Temperature = 0.3, Messages = new List<ChatMessage> { new("system", "你是一个搜索策略助手。请把用户问题拆成 1 到 3 个适合搜索引擎的关键词组合,只返回 JSON,格式为 {\"queries\": [\"...\"]}。"), new("user", userQuery) } }; var response = await _grokClient.CompleteAsync(request, ct); var content = response.Choices?.FirstOrDefault()?.Message?.Content; // 解析 JSON var parsed = JsonSerializer.Deserialize<QueryPlan>(content ?? "{\"queries\":[]}"); return parsed?.Queries ?? new List<string>(); }第二步,并行调用搜索接口。注意这里要做并发度控制,不能一次放出几十个请求:
private readonly SemaphoreSlim _searchThrottle = new(4); public async Task<List<SearchResult>> SearchAsync(IEnumerable<string> queries, CancellationToken ct) { using var scope = new SemaphoreSlimLimit(_searchThrottle); var tasks = queries.Select(async q => { await _searchThrottle.WaitAsync(TimeSpan.FromSeconds(3), ct); try { return await _searchProvider.SearchAsync(q, ct); } finally { _searchThrottle.Release(); } }); var results = await Task.WhenAll(tasks); return results.SelectMany(x => x).DistinctBy(r => r.Url).ToList(); }第三步,把搜索结果拼成上下文字符串,再次调用 Grok 4.20 生成最终答案。这一步是最容易出效果的,也最容易翻车。
var contextBuilder = new StringBuilder(); var index = 1; foreach (var result in topResults) { contextBuilder.AppendLine($"[{index}] {result.Title}"); contextBuilder.AppendLine($"URL: {result.Url}"); contextBuilder.AppendLine($"摘要: {result.Snippet}"); contextBuilder.AppendLine(); index++; } var finalRequest = new ChatRequest { Model = _options.Model, Temperature = 0.2, MaxTokens = 1500, Tools = new List<ChatTool> { BuildSubmitAnswerTool() }, Messages = new List<ChatMessage> { new("system", $"你是严谨的搜索助手。请阅读搜索结果并回答问题。\n规则:\n1. 只能使用下面提供的搜索结果。\n2. 答案必须引用来源编号 [1]、[2]。\n3. 如果搜索结果不足以回答,直接说信息不足。\n4. 必须通过 submit_search_answer 工具提交答案。\n\n搜索结果:\n{contextBuilder}"), new("user", userQuery) } }; var finalResponse = await _grokClient.CompleteAsync(finalRequest, ct); var toolCall = finalResponse.Choices?.FirstOrDefault()?.Message?.ToolCalls?.FirstOrDefault(); if (toolCall is null) { throw new InvalidOperationException("Model did not return a tool call"); } var answer = JsonSerializer.Deserialize<SearchAnswer>(toolCall.Function.Arguments);这里把 temperature 降到 0.2,是为了减少模型在引用上的“创造性发挥”。搜索场景里答案唯一性很重要,温度越低越好。
4. 高可靠落地:重试、缓存、限流、熔断与降级
4.1 重试策略:指数退避为什么有效
大模型接口经常会碰到两类瞬时错误:网络抖动导致连接失败,以及服务方限流返回 429。这两种情况,立即重试通常没有用,需要等一小段时间再试。
教科书里常说的是指数退避:第一次等待 1 秒,第二次 2 秒,第三次 4 秒。但直接这样用有一个问题:如果所有客户端都在同一时间失败,它们会按同样的节奏重试,形成“惊群效应”,把服务方再次打爆。所以要在退避时间上加一个随机抖动。
我常用的模式是这样的:
public static async Task<T> RetryAsync<T>( Func<CancellationToken, Task<T>> action, int maxRetries, CancellationToken ct) { var baseDelay = TimeSpan.FromSeconds(1); for (var attempt = 0; ; attempt++) { try { return await action(ct); } catch (Exception ex) when (attempt < maxRetries - 1 && IsRetryable(ex)) { var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 200)); var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)) + jitter; await Task.Delay(delay, ct); } } } private static bool IsRetryable(Exception ex) { return ex is HttpRequestException || ex is TaskCanceledException || (ex is GrokApiException api && api.StatusCode == HttpStatusCode.TooManyRequests); }在分布式系统里,随机抖动不是可有可无的优化,而是防止重试风暴的必要手段。这条经验在接任何外部 API 时都适用。
4.2 缓存设计:省下真金白银
AI 服务最大的成本就是 Token。同一个问题,如果用户反复搜,每次都调模型,不仅慢还很烧钱。给最终答案加缓存是性价比最高的一步。
用IMemoryCache就好,不需要一开始就上分布式缓存:
private readonly IMemoryCache _cache; public async Task<SearchAnswer> GetAnswerAsync(string userQuery, CancellationToken ct) { var cacheKey = $"grok:answer:{ComputeHash(userQuery)}"; if (_cache.TryGetValue(cacheKey, out SearchAnswer? cached)) { return cached!; } var answer = await InvokeModelPipelineAsync(userQuery, ct); _cache.Set(cacheKey, answer, TimeSpan.FromMinutes(15)); return answer; }缓存 Key 不能直接用用户输入的原文。用户可能只改了一个标点,键就会不同。我习惯对原文做 SHA256 后取前 16 位作为 Key,同时把模型参数(比如 temperature、model 版本)也拼进去,避免换参数后拿到旧缓存。
还有两个细节容易被忽略。
第一个是空结果缓存。如果用户搜索本身没有结果,或者模型判定“信息不足”,你也要把这个结果缓存几分钟,但 TTL 要短一些,比如 30 秒。否则一个冷门问题会在短时间内反复打模型。
第二个是防止缓存击穿。当同一个 Key 的缓存过期,突然有几十个并发请求同时发现没有缓存,它们会一起打模型。解决思路是“单飞”:同一个 Key 同时只允许一个请求进入模型调用,其他请求等着复用结果。用ConcurrentDictionary<string, SemaphoreSlim>可以简单实现:
private readonly ConcurrentDictionary<string, SemaphoreSlim> _inflight = new(); public async Task<SearchAnswer> GetAnswerWithSingleFlightAsync(string userQuery, CancellationToken ct) { var key = ComputeHash(userQuery); var semaphore = _inflight.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); await semaphore.WaitAsync(ct); try { if (_cache.TryGetValue(key, out SearchAnswer? cached)) { return cached!; } var answer = await InvokeModelPipelineAsync(userQuery, ct); _cache.Set(key, answer, TimeSpan.FromMinutes(15)); return answer; } finally { semaphore.Release(); _inflight.TryRemove(key, out _); } }4.3 并发限流:给模型调用加一道闸门
就算有缓存,搜索热点问题仍然会产生大量真实请求。模型服务方是按配额限流的,突然的并发很可能触发 429。与其被对方限流,不如自己在本地先限流。
用一个全局SemaphoreSlim,把同时正在执行的模型请求数量限制住,比如 8。这里有一个常见误区:信号量只是控制并发,不能当队列用。当并发已满,后续请求应该快速失败或者排队一小段时间,而不是无线等待。
private readonly SemaphoreSlim _modelGate = new(8); public async Task<ChatResponse> CallModelAsync(ChatRequest request, CancellationToken ct) { if (!await _modelGate.WaitAsync(TimeSpan.FromSeconds(5), ct)) { throw new TooManyRequestsException("模型并发已满"); } try { return await _grokClient.CompleteAsync(request, ct); } finally { _modelGate.Release(); } }这个“5 秒拿不到闸门就放弃”的策略,保证了系统在流量尖峰时不会无限堆积任务。你宁可让用户看到“稍后再试”,也不能把下游模型接口打挂。
4.4 熔断与降级:让服务在故障中“活”下来
重试和限流解决的是瞬时故障,但如果 Grok 4.20 彻底不可用,或者网络被切断,重试只会让情况更糟。这时候需要熔断器。
概念不复杂:连续失败达到阈值后,熔断器打开,后续请求直接短路,不再调用模型;过一段时间进入半开状态,放一个试探请求;试探成功则关闭熔断器,失败则继续打开。
在生产里我直接推荐用 Polly 的熔断策略,配置一次就能用:
var retry = Policy .Handle<HttpRequestException>() .Or<GrokApiException>(ex => ex.StatusCode == HttpStatusCode.TooManyRequests) .WaitAndRetryAsync(new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4) }); var circuitBreaker = Policy .Handle<HttpRequestException>() .Or<GrokApiException>(ex => ex.StatusCode >= HttpStatusCode.InternalServerError) .CircuitBreakerAsync( exceptionsAllowedBeforeBreaking: 5, durationOfBreak: TimeSpan.FromSeconds(30));手写一个简单的熔断器也可以,但边界情况很多,不如用成熟库。
降级是熔断之后必须配套的东西。我见过不少项目只做了熔断,熔断一打开,用户直接看到报错页面。更好的做法是:熔断打开时,如果缓存里有旧答案,就返回缓存并标注“内容可能不是最新”;如果没有缓存,就返回一个固定的引导提示,比如“搜索服务暂时开小差,请稍后再试”。
4.5 让每个环节都有日志
可靠的系统不是靠猜出来的,而是靠日志看出来的。我会在编排器里加一个统一的日志埋点,记录整条链路的耗时:
var sw = Stopwatch.StartNew(); var answer = await GetAnswerWithSingleFlightAsync(userQuery, ct); sw.Stop(); _logger.LogInformation( "Search pipeline finished. queryHash={QueryHash}, hits={HitCount}, elapsed={ElapsedMs}ms, cacheHit={CacheHit}", queryHash, answer.Citations.Count, sw.ElapsedMilliseconds, cacheHit);日志里不要记录用户原始问题,因为那可能包含隐私。记录一个不可逆的 hash 值就足够了,这既方便排查,又避免合规风险。
5. 实战中高频问题与排查方法
5.1 一张速查表解决 80% 的问题
下面这些是我实际运维中反复遇到的场景,整理成表格方便你对照排查。
| 症状 | 可能原因 | 根治方案 |
|---|---|---|
请求总是TaskCanceledException | 超时设置过短,或网络到模型服务延迟大 | 区分连接超时和总超时,总超时给到 30 秒,业务要求更严就做流式响应 |
| 返回 429 Too Many Requests | 并发超过配额,或重试太激进 | 本地信号量限流,配合指数退避重试,读取Retry-After响应头 |
| 返回 400 Bad Request | messages结构不对,工具参数 Schema 不合法,上下文过长 | 校验 JSON 日志,检查工具调用参数名是否完全匹配 |
| JSON 解析失败 | 模型输出被截断,或包裹了 Markdown 代码块 | 改用工具调用,把max_tokens调大,解析失败时重试一次并降低温度 |
| 答案里的引用 URL 打不开 | 模型幻觉,引用了不存在的链接 | 搜索结果强制编号,在 system 提示词中要求只能引用给定编号 |
| 中文返回乱码 | 下游搜索结果本身编码异常,或响应处理时用错解码 | 统一 UTF-8,对搜索服务返回内容做编码检测 |
| 熔断频繁触发 | 并发限流太小导致大量排队超时被计为失败 | 先看日志区分超时来源,再调整信号量与超时阈值 |
5.2 一个典型调优案例
我用“模拟项目 X”来说明。这个项目是内部知识库搜索,用户会问“上个月的报表在哪里”。刚上线时,模型经常生成一个看起来很像样、但实际上不存在的链接。
排查过程是这样的:先看日志里模型收到的搜索结果,发现确实有结果被拼进去了,但因为上下文太长,代码对结果做了截断,只留了前面几条。而模型回答引用的链接,恰好是被截断掉的那部分。也就是说,模型压根没看到那些候选结果,它只是顺着常见路径幻觉了一个。
解决方式有三条:第一,按相关度排序后,只给模型前 8 条高质量结果,不要贪多;第二,每条结果强制编号,并明确要求“你只能引用列表中的编号”;第三,把 temperature 降到 0.2 以下。改了这三处之后,引用准确率明显提升,错误率从百分之十几降到个位数。
这个案例说明一个问题:很多所谓“模型幻觉”,其实是你给模型的上下文不够明确。模型不知道有哪些答案可选,自然就会自己编。
5.3 健康检查与主动探活
线上服务必须有健康检查接口。K8s 或者容器平台会定期请求/healthz,如果服务依赖的模型接口出问题,最好在健康检查阶段就暴露出来,而不是等用户报障。
用 ASP.NET Core 自带健康检查就行:
builder.Services.AddHealthChecks() .AddCheck("grok_api", async () => { try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); var response = await _httpClient.GetAsync("/models", cts.Token); return response.IsSuccessStatusCode ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy($"Grok API returned {response.StatusCode}"); } catch (Exception ex) { return HealthCheckResult.Unhealthy(ex.Message); } }); app.MapHealthChecks("/healthz");这里的探活请求不要调真正的对话接口,那太贵了。用一个轻量的元数据接口就够了,能确认服务可连通即可。
最后说一个成本控制的小技巧。Grok 4.20 返回结果里的usage字段,别忘了解析和记录。它包含 prompt_tokens 和 completion_tokens 两个数字。你把每次调用的 Token 用量写进结构化日志,月底一统计,就能知道哪些功能最烧钱,进而优化提示词长度、缓存策略或者 max_tokens 上限。我就靠这个数据,把一个内部搜索功能的成本压掉了近三分之一。
做 AI 搜索服务,调通接口真的只是第一步。重试、缓存、熔断、限流、日志这五件事,我建议你一开始就规划进去,不要等项目跑起来再补。等线上出故障再补救,你要面对的就不只是代码问题,还有正在等待结果的用户。