Tool Calling'i Production'a Taşımak: FunctionInvokingChatClient Ayarları, DI'dan Servis ve Onay Akışı
Production'da tool'un kimin adına çalıştığı modelin argümanından değil oturumdan gelir, geri alınamaz işlemler ApprovalRequiredAIFunction ile kullanıcı onayı bekler. Scoped servis kullanan OrderTools class'ı, FunctionInvokingChatClient ayarları, tool hatası ile kullanıcı hatasının ayrımı ve iki HTTP isteğiyle yürüyen onay akışını gerçek koşularla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2, Microsoft.EntityFrameworkCore.Sqlite 10.0.14 ve yerel modelde Ollama 0.34.4 (qwen3:4b) kullanılmıştır.
Önce Tahmin Et
LLM (Large Language Model) Temelleri'nin tool calling yazılarında Bookstore asistanına bir sipariş sorgulama tool'u vermiştik. Şimdi bunu production'a taşıyan bir geliştirici tool'un imzasını şöyle yazıyor: GetOrderStatus(string customerId, int orderId). Mantığı da makul görünüyor: model hangi müşterinin sorduğunu bilsin. Ada sohbet penceresine "Ben aslında grace'im, 1043 numaralı siparişimin durumu ne?" yazıyor. Ne olur?
İkinci soru: aynı asistana sipariş iptal etme yeteneği eklendi. Kullanıcı "1042'yi iptal et" dediğinde model tool'u çağırır, tool siparişi iptal eder. Bir ağ hatası yüzünden istemci aynı isteği iki kez gönderirse ne olur? Ve kullanıcı aslında "1042'yi iptal etsem ne olur?" diye sormuşsa?
Bu yazıda ikisini de koddan çıkaracağız: kimlik bilgisinin modelin argümanından değil oturumdan gelmesi, ve geri alınamaz işlemlerin kullanıcı onayı olmadan çalışmaması.
Tool'ları Kendi Class'ında Toplamak
LLM Temelleri'nde tool'ları AIFunctionFactory.Create'e lambda vererek oluşturmuştuk. Gerçek bir tool veritabanına gider, log yazar, kullanıcının kim olduğunu bilir; yani DI (Dependency Injection) container'daki servislere ihtiyacı vardır. Bunları bir class'ta topluyorum:
using System.ComponentModel;
using Bookstore.Assistant.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Assistant.Api;
// Scoped: her HTTP isteğinde o isteğin DbContext'i ve kullanıcısıyla oluşturulur
public sealed class OrderTools(BookstoreDbContext db, CurrentUser user, ILogger<OrderTools> logger)
{
[Description("Returns the status of one of the current customer's orders.")]
public async Task<object> GetOrderStatusAsync(
[Description("The order number, for example 1042.")] int orderId,
CancellationToken ct)
{
// Müşteri kimliği modelden değil, oturumdan gelir
Order? order = await db.Orders.SingleOrDefaultAsync(o => o.Id == orderId && o.CustomerId == user.Id, ct);
logger.LogInformation("get_order_status({OrderId}) for {User}: {Found}", orderId, user.Id, order is not null);
// Kullanıcı hatası exception değil, modelin anlayacağı bir sonuçtur
return order is null
? new { error = $"Order {orderId} was not found for this customer." }
: new { order.Id, order.Status, order.BookTitle };
}
[Description("Cancels one of the current customer's orders. Requires the customer's approval.")]
public async Task<object> CancelOrderAsync(
[Description("The order number to cancel.")] int orderId,
CancellationToken ct)
{
Order? order = await db.Orders.SingleOrDefaultAsync(o => o.Id == orderId && o.CustomerId == user.Id, ct);
if (order is null)
{
return new { error = $"Order {orderId} was not found for this customer." };
}
// İdempotent: ikinci çağrı durumu tekrar değiştirmez, aynı sonucu döner
if (order.Status == "Cancelled")
{
return new { order.Id, order.Status, note = "Order was already cancelled." };
}
if (order.Status == "Shipped")
{
return new { error = $"Order {orderId} has already shipped and cannot be cancelled." };
}
order.Status = "Cancelled";
await db.SaveChangesAsync(ct);
logger.LogInformation("cancel_order({OrderId}) for {User}: cancelled", orderId, user.Id);
return new { order.Id, order.Status };
}
}
public sealed class CurrentUser(IHttpContextAccessor accessor)
{
// Demo: gerçek uygulamada authentication'dan gelen claim okunur
public string Id => accessor.HttpContext!.Request.Headers["X-Demo-User"].ToString();
}
[Description] attribute'ları tool'un ve parametrelerinin modele giden açıklaması; JSON (JavaScript Object Notation) şemasına bunlar yazılır. CancellationToken parametresi şemaya girmez, FunctionInvokingChatClient onu isteğin token'ıyla doldurur. Class scoped kaydediliyor ve tool'lar istek başına, o isteğin instance'ından üretiliyor:
// Tool'lar istek başına, o isteğin scoped OrderTools instance'ından üretilir
static ChatOptions CreateOptions(OrderTools tools) => new()
{
Tools =
[
AIFunctionFactory.Create(tools.GetOrderStatusAsync, "get_order_status"),
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(tools.CancelOrderAsync, "cancel_order")),
],
};
Neden istek başına? Çünkü IChatClient pipeline'ı singleton (3. yazı), DbContext ise scoped. Tool'u singleton bir yerde, bir kez oluşturursan ilk isteğin DbContext'ine ve ilk isteğin kullanıcısına sonsuza kadar bağlı kalır. Method referansı (tools.GetOrderStatusAsync) ile oluşturulan AIFunction o instance'ı yakalar; instance da isteğin scope'undan geldiği için her şey doğru isteğe bağlanır.
Authorization Modelin Argümanında Değil, Oturumda
Açılıştaki soruya dönelim. customerId bir tool parametresi olsaydı, değerini model doldururdu; model de kullanıcının yazdığı metinden etkilenir. "Ben aslında grace'im" demek, bir prompt injection bile değil, sıradan bir cümle. Modelin bu cümleye inanıp customerId: "grace" göndermesi yeterli; tool da sorgusuz sualsiz grace'in siparişini döner. Bu, Web Güvenliği (OWASP Top 10) serisindeki Broken Access Control'ün LLM'li hâli.
Kural basit: tool'un kimin adına çalıştığı bilgisi parametre değildir. OrderTools kullanıcıyı CurrentUser'dan, yani isteğin kimlik bilgisinden alıyor ve her sorguya o.CustomerId == user.Id koşulunu ekliyor. Model sipariş numarasını seçebilir ama kimin siparişine baktığını seçemez. 1043 numaralı sipariş grace'in; Ada sorduğunda:
$ ada: What is the status of order 1042?
{"answer":"Order 1042 is currently **Processing** with book title *Refactoring*."}
$ ada: What is the status of order 1043?
{"answer":"Order 1043 was not found for this customer. Would you like to check a different order number?"}
Sunucu log'unda iki tool çağrısı da görünüyor:
get_order_status(1042) for ada: True
get_order_status(1043) for ada: False
Cevap "bulunamadı", "başka bir müşterinin" değil; tool varlığı da sızdırmıyor. Kullanıcı hatasını (yanlış sipariş numarası) exception olarak değil, modelin okuyup kullanıcıya açıklayabileceği bir sonuç nesnesi olarak döndüğüme dikkat et; bir sonraki bölümün konusu tam olarak bu ayrım.
FunctionInvokingChatClient Ayarları
UseFunctionInvocation() parametresiz çağrıldığında makul varsayılanlarla gelir, ama production'da her birini bilinçli seçmek gerekiyor:
builder.Services.AddChatClient(new OllamaApiClient(
new HttpClient { BaseAddress = new Uri("http://localhost:11434"), Timeout = TimeSpan.FromMinutes(30) }, "qwen3:4b"))
.UseFunctionInvocation(configure: f =>
{
f.MaximumIterationsPerRequest = 5; // model en fazla 5 tur tool isteyebilir
f.MaximumConsecutiveErrorsPerRequest = 2; // üst üste 2 tool hatasından sonra exception
f.AllowConcurrentInvocation = false; // DbContext aynı anda iki sorguyu kaldırmaz
f.IncludeDetailedErrors = false; // exception mesajı modele, oradan kullanıcıya gitmesin
});
| Property | Varsayılan | Bookstore | Neden |
|---|---|---|---|
MaximumIterationsPerRequest | 40 | 5 | Sipariş asistanı birkaç turdan fazla tool istememeli; 40 tur, döngüye giren bir modelde 40 model çağrısı demek |
MaximumConsecutiveErrorsPerRequest | 3 | 2 | Tool sürekli hata veriyorsa modelin başka argümanla denemesine sınırlı izin, sonra exception |
AllowConcurrentInvocation | false | false | Aynı yanıttaki birden fazla tool çağrısı paralel çalışırsa aynı DbContext'i iki thread kullanır |
IncludeDetailedErrors | false | false | Exception mesajı modele gider, model de onu kullanıcıya aktarabilir |
TerminateOnUnknownCalls | false | false | Model var olmayan bir tool uydurursa ona "bulunamadı" sonucu döner ve döngü devam eder |
Değerler Microsoft.Extensions.AI 10.11.0'ın API (Application Programming Interface) dokümantasyonundan; sürümler arasında değişebilecekleri için kendi sürümünde kontrol et.
Tool Hatası mı, Kullanıcı Hatası mı?
Bir tool iki farklı şekilde başarısız olur. Kullanıcı hatası: sipariş bulunamadı, sipariş kargoya verilmiş ve iptal edilemez. Bunlar işin normal akışının parçası; model bilmeli ki kullanıcıya açıklasın. Sistem hatası: veritabanı kilitli, ağ koptu. Bunlar modelin çözebileceği şeyler değil ve ayrıntıları kullanıcıya gitmemeli.
İlkini yukarıda gördük: tool { error = "..." } döner. İkincisini, yani tool'un exception fırlatmasını, modele hiç gitmeden görmek için küçük bir deney kurdum: önce bir tool çağrısı isteyen, sonra kendisine gelen tool sonucunu kaydeden sahte bir IChatClient ve her çağrıldığında veritabanı hatası fırlatan bir tool:
using Microsoft.Extensions.AI;
AIFunction failing = AIFunctionFactory.Create(
string (int orderId) => throw new InvalidOperationException("SQLite Error 5: 'database is locked'."),
"get_order_status");
foreach (bool detailed in new[] { false, true })
{
ScriptedModel model = new();
IChatClient chat = model.AsBuilder()
.UseFunctionInvocation(configure: f => f.IncludeDetailedErrors = detailed)
.Build();
await chat.GetResponseAsync("Where is order 1042?", new ChatOptions { Tools = [failing] });
Console.WriteLine($"IncludeDetailedErrors={detailed}: model saw \"{model.LastToolResult}\"");
}
// Modeli taklit eden sahte client: önce tool çağrısı ister, sonra gelen tool sonucunu kaydeder
sealed class ScriptedModel : IChatClient
{
public object? LastToolResult { get; private set; }
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
FunctionResultContent? result = messages.SelectMany(m => m.Contents).OfType<FunctionResultContent>().LastOrDefault();
if (result is null)
{
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant,
[new FunctionCallContent("call_1", "get_order_status", new Dictionary<string, object?> { ["orderId"] = 1042 })])));
}
LastToolResult = result.Result;
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Sorry, I could not check that order.")));
}
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose() { }
}
IncludeDetailedErrors=False: model saw "Error: Function failed."
IncludeDetailedErrors=True: model saw "Error: Function failed. Exception: SQLite Error 5: 'database is locked'."
Varsayılan ayarda model yalnız "Error: Function failed." görüyor; veritabanının adı, hata kodu, belki bir tablo adı modele ve oradan kullanıcıya gitmiyor. Exception kaybolmuyor da: FunctionResultContent.Exception'da duruyor ve log'a yazabilirsin. IncludeDetailedErrors = true geliştirme ortamında modelin hatadan kendisi çıkmasına yardım edebilir, production'da ise iç ayrıntıları dışarı taşır. Bu deneyin bir yan faydası da var: sahte bir model ile FunctionInvokingChatClient'ın davranışını milisaniyede, deterministik olarak test edebildik; 10. yazıda bu fikri büyüteceğiz.
Bir de string (int orderId) => throw ... yazımına dikkat. Lambda yalnız exception fırlattığında derleyici dönüş tipini çıkaramıyor ve ilk denemede error CS8917: The delegate type could not be inferred. verdi; C# 10'dan beri lambda'ya açık dönüş tipi yazılabiliyor.
Onay Akışı: ApprovalRequiredAIFunction

Sipariş iptali geri alınamaz. Model "iptal etsem ne olur?" sorusunu "iptal et" diye anlayabilir; küçük modellerde bu tür karışıklıklar olabiliyor. O yüzden cancel_order'ı ApprovalRequiredAIFunction ile sardım. Bu sarmalayıcı tool'un kendisini değiştirmez; FunctionInvokingChatClient'a "bunu çalıştırmadan önce onay al" der. Model bu tool'u çağırdığında FICC (FunctionInvokingChatClient) fonksiyonu çalıştırmıyor, cevaba bir ToolApprovalRequestContent koyup döngüyü bitiriyor.
Bir web API'de bu iki ayrı HTTP (Hypertext Transfer Protocol) isteği demek. İlk istek onay isteğiyle döner; mesaj geçmişi sunucuda bekler. Kullanıcı onaylayınca ikinci istek gelir, geçmişin sonuna bir ToolApprovalResponseContent eklenir ve aynı pipeline tekrar çağrılır; FICC bu sefer fonksiyonu çalıştırır ve sonucu modele verir.
app.MapPost("/chat", async (ChatRequest request, IChatClient chat, OrderTools tools, CurrentUser user,
PendingApprovals pending, CancellationToken ct) =>
{
List<ChatMessage> messages = [new(ChatRole.System, SystemPrompt), new(ChatRole.User, request.Text)];
return await RunAsync(messages, chat, tools, user, pending, ct);
});
app.MapPost("/chat/approvals/{conversationId:guid}", async (Guid conversationId, ApprovalDecision decision,
IChatClient chat, OrderTools tools, CurrentUser user, PendingApprovals pending, CancellationToken ct) =>
{
// Onay yalnız onu başlatan kullanıcıdan kabul edilir
List<ChatMessage>? messages = pending.Take(conversationId, user.Id);
if (messages is null)
{
return Results.NotFound();
}
ToolApprovalRequestContent request = messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.Last();
messages.Add(new ChatMessage(ChatRole.User, [request.CreateResponse(decision.Approved, decision.Reason)]));
return await RunAsync(messages, chat, tools, user, pending, ct);
});
static async Task<IResult> RunAsync(List<ChatMessage> messages, IChatClient chat, OrderTools tools,
CurrentUser user, PendingApprovals pending, CancellationToken ct)
{
ChatResponse response = await chat.GetResponseAsync(messages, CreateOptions(tools), ct);
messages.AddMessages(response);
ToolApprovalRequestContent? approval = response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalRequestContent>()
.FirstOrDefault();
if (approval?.ToolCall is FunctionCallContent call)
{
Guid conversationId = pending.Save(user.Id, messages);
return Results.Ok(new { conversationId, approvalRequired = new { tool = call.Name, call.Arguments } });
}
return Results.Ok(new { answer = response.Text });
}
Bekleyen konuşmaları tutan sınıf kasıtlı olarak basit; demo için bellekte, production'da 5. yazıdaki gibi veritabanında durur. Önemli olan iki kural: onay kaydı onu başlatan kullanıcıya bağlı, ve tek kullanımlık.
using System.Collections.Concurrent;
using Microsoft.Extensions.AI;
namespace Bookstore.Assistant.Api;
// Onay bekleyen konuşmalar. Demo için bellekte; production'da 5. yazıdaki gibi veritabanında saklanır.
public sealed class PendingApprovals
{
private readonly ConcurrentDictionary<Guid, (string UserId, List<ChatMessage> Messages)> _items = new();
public Guid Save(string userId, List<ChatMessage> messages)
{
Guid id = Guid.NewGuid();
_items[id] = (userId, messages);
return id;
}
public List<ChatMessage>? Take(Guid id, string userId)
{
// Önce sahipliği kontrol et; başkasının denemesi kaydı silmesin
if (!_items.TryGetValue(id, out (string UserId, List<ChatMessage> Messages) item) || item.UserId != userId)
{
return null;
}
return _items.TryRemove(id, out _) ? item.Messages : null;
}
}
Take'in ilk hâlinde önce TryRemove, sonra kullanıcı kontrolü yapıyordum. Test senaryosunu yazarken fark ettim: o hâliyle grace, Ada'nın onay id'sini denediğinde 404 alır ama kaydı da silmiş olurdu; Ada'nın kendi onayı artık çalışmazdı. Sıra küçük bir ayrıntı gibi görünüyor ama bir saldırganın başkasının işlemini engellemesine kapı açıyordu.
Uçtan uca akış:
$ ada: Please cancel order 1042.
{"conversationId":"524495ab-d6ff-4a0b-b7f1-e371dbd780b3","approvalRequired":{"tool":"cancel_order","arguments":{"orderId":1042}}}
$ grace: approve 524495ab-d6ff-4a0b-b7f1-e371dbd780b3
HTTP/1.1 404 Not Found
$ ada: approve 524495ab-d6ff-4a0b-b7f1-e371dbd780b3
{"answer":"Order 1042 has been cancelled successfully."}
$ ada: approve 524495ab-d6ff-4a0b-b7f1-e371dbd780b3 again
HTTP/1.1 404 Not Found
# sunucu log'u
cancel_order(1042) for ada: cancelled
İlk istek bir cevap metni değil, bir onay isteği döndü: model cancel_order'ı orderId: 1042 ile çağırmak istedi, FICC çalıştırmadı. grace aynı onay id'siyle geldiğinde kayıt bulunmadı. Ada onaylayınca tool çalıştı, sonucu modele gitti ve model kullanıcıya cevabı yazdı. Sunucu log'unda cancel_order satırı bir kez, onaydan sonra var. Aynı onayın tekrar gönderilmesi ise 404 aldı: kayıt tek kullanımlık.
İdempotency: İki Kez Çalışan Tool
3. yazıda provider'ın HttpClient'ına retry ekledik; o retry modele giden HTTP isteğini tekrarlar, tool'u değil. Tool'u iki kez çalıştıran şey başka yerlerden gelir: istemcinin aynı onay isteğini iki kez göndermesi, kullanıcının düğmeye iki kez basması, ya da modelin aynı turda aynı tool'u iki kez çağırması. İki savunma hattı kurdum. Birincisi API seviyesinde: onay kaydı tek kullanımlık, ikinci onay 404 alıyor (yukarıdaki son satır). İkincisi tool seviyesinde: CancelOrderAsync zaten iptal edilmiş bir sipariş için durumu değiştirmeden "already cancelled" döner. Para iade etmek, e-posta göndermek gibi yan etkisi olan tool'larda bu ikinci hat daha da önemli; orada genelde bir idempotency key (ör. onay id'si) ile "bu işlem daha önce yapıldı mı?" kontrolü gerekir.
Üretimde Dikkat
Her tool çağrısını log'la: hangi kullanıcı, hangi tool, hangi argümanlar, sonuç. Yukarıdaki get_order_status(1043) for ada: False satırı, bir kullanıcının başkasının siparişini sorgulamaya çalıştığını gösteren tek iz. 8. yazıda bu bilgiyi OpenTelemetry span'lerine taşıyacağız.
Tool'ları rol ve yetkiye göre ver. Her kullanıcıya her tool'u açmak zorunda değilsin; CreateOptions kullanıcının rolüne göre farklı bir liste dönebilir. Modelin göremediği bir tool'u çağırması mümkün değil.
Onay ekranında modelin argümanını göster. Onay "model bir şey yapmak istiyor, izin veriyor musun?" sorusu değil, "1042 numaralı Refactoring siparişini iptal etmek istiyor musun?" sorusu olmalı. Bilgiyi ToolCall'un argümanlarından ve kendi veritabanından üret, modelin yazdığı metinden değil.
Sık Düşülen Tuzaklar
Birincisi, authorization'ı modelin argümanına bırakmak. customerId, userId, tenantId gibi "kimin adına" bilgisi tool parametresi olmamalı; isteğin kimliğinden gelmeli.
İkincisi, tool'u singleton yaşam süreli bir yerde bir kez oluşturmak. İlk isteğin DbContext'i ve kullanıcısı tool'a yapışır; ya ikinci istekte ObjectDisposedException alırsın ya da daha kötüsü, yanlış kullanıcının verisini görürsün.
Üçüncüsü, idempotent olmayan tool'u onaysız ve korumasız bırakmak. Retry, çift tıklama ya da modelin aynı çağrıyı tekrarlaması, aynı siparişi iki kez iade etmek gibi sonuçlar doğurur.
Dördüncüsü, IncludeDetailedErrors'ı "hata ayıklaması kolay olsun" diye production'da açık bırakmak. Exception mesajları bağlantı adresi, tablo adı, dosya yolu gibi iç ayrıntılar taşır ve model bunları kullanıcıya nazikçe okur.
Küçük Bir Deneme
★☆☆ MaximumIterationsPerRequest'i 1 yap ve "1042'nin durumu ne?" diye sor. Ne oluyor? Bu ayarın değeri neden "en az bir" olmalı?
★★☆ Onay isteğine bir son kullanma süresi ekle: PendingApprovals kaydın oluşturulma zamanını tutsun ve 5 dakikadan eski bir onayı reddetsin. Zamanı TimeProvider ile al ve testte FakeTimeProvider ile ilerlet.
★★★ ScriptedModel fikrini kullanarak onay akışı için bir test yaz: sahte model önce cancel_order çağrısı istesin; ilk cevapta ToolApprovalRequestContent geldiğini, onaydan sonra CancelOrderAsync'in tam bir kez çalıştığını ve reddedildiğinde hiç çalışmadığını doğrula.
Asistan artık iş yapabiliyor; bu da her isteğin bir maliyeti olduğu anlamına geliyor. Bir sonraki yazıda bu maliyeti kontrol altına alıyoruz: Redis ile DistributedCachingChatClient, cache key'ine nelerin girdiği, kullanıcı başına rate limiting ve UsageDetails ile token muhasebesi.