Sohbet Geçmişini Saklamak: Stateless API, ConversationId, EF Core ve Chat Reducer'lar
Stateless bir provider'da sohbet geçmişini senin kodun tutar: ChatMessage'ları tool call ve tool result content'leriyle birlikte AIJsonUtilities ile JSON'a çevirip EF Core'da kullanıcı ve conversation bazında saklıyoruz. Sahiplik kontrolünü sorguya koyuyor, ConversationId ile stateful provider'ları da destekliyor ve MessageCountingChatReducer ile SummarizingChatReducer'ın gerçek çıktılarını karşılaştırıyoruz.
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
Ada, Bookstore asistanına "Merhaba, ben Ada, yalnız yazılım tasarımı kitapları okurum" yazıyor. Bir dakika sonra aynı sohbet penceresinden "Adım ne ve ne tür kitaplar severim?" diye soruyor. Arkada Ollama'daki qwen3:4b var. Model cevabı bilir mi? Bilirse bu bilgiyi kim tutuyor: model mi, Ollama mı, senin kodun mu?
İkinci soru: geçmiş uzadı ve Microsoft.Extensions.AI'ın MessageCountingChatReducer'ını 4 mesaj sınırıyla devreye soktun. Geçmişte bir system prompt, Ada'nın tanışma mesajı, bir sipariş sorusu, bir tool call, bir tool result ve iki asistan cevabı var. Modele hangileri gider?
Birincinin cevabı "senin kodun", ikincisinin cevabı ise çoğu kişinin tahmin ettiğinden farklı; ikisini de gerçek çıktılarla göreceğiz.
Stateless Provider'da Geçmişi Kim Tutar?
LLM (Large Language Model) Temelleri'nin context window yazısında gördüğümüz gibi model bir önceki isteği hatırlamaz; her istekte ona ne gönderirsen o kadarını bilir. Ollama'nın /api/chat endpoint'i ve OpenAI'ın Chat Completions API'si (Application Programming Interface) bu açıdan stateless: sohbetin devamı için bütün geçmişi her istekte yeniden gönderirsin. IChatClient bu modeli doğrudan yansıtıyor: GetResponseAsync bir mesaj listesi alır, liste sohbetin tamamıdır.
Bir web API'de bu listenin iki isteği arasında yaşaması gerekiyor. Bellekte tutmak (bir Dictionary'de) ilk denemede çalışır ama uygulama yeniden başladığında, ikinci bir instance devreye girdiğinde ya da kullanıcı başka cihazdan bağlandığında kaybolur. Kalıcı olması gereken bir veri bu, dolayısıyla veritabanına gidiyor.
ChatMessage'ı Saklamak: AIJsonUtilities ile JSON
Saklanacak şey yalnız metin değil. Bir asistan mesajı metin yerine bir tool call (FunctionCallContent) taşıyabilir, onun cevabı ChatRole.Tool rolünde bir FunctionResultContent'tir. Bunları kaybedersen model bir sonraki turda "sipariş 1042 kargoda" bilgisini nereden aldığını bilemez. En sağlam yol ChatMessage'ı olduğu gibi JSON'a (JavaScript Object Notation) çevirmek. MEAI'nin (Microsoft.Extensions.AI) bunun için hazır ayarları var: AIJsonUtilities.DefaultOptions.
using System.Text.Json;
using Microsoft.Extensions.AI;
ChatMessage call = new(ChatRole.Assistant,
[new FunctionCallContent("call_1", "get_order_status", new Dictionary<string, object?> { ["orderId"] = 1042 })]);
ChatMessage result = new(ChatRole.Tool,
[new FunctionResultContent("call_1", new { status = "Shipped", carrier = "UPS" })]);
foreach (ChatMessage message in new[] { call, result })
{
string json = JsonSerializer.Serialize(message, AIJsonUtilities.DefaultOptions);
Console.WriteLine(json);
ChatMessage back = JsonSerializer.Deserialize<ChatMessage>(json, AIJsonUtilities.DefaultOptions)!;
Console.WriteLine($" -> {back.Role}: {back.Contents[0].GetType().Name}");
}
// Varsayılan serializer ayarlarıyla ne olur?
string plain = JsonSerializer.Serialize(call);
Console.WriteLine(plain);
ChatMessage plainBack = JsonSerializer.Deserialize<ChatMessage>(plain)!;
Console.WriteLine($" -> {plainBack.Role}: {plainBack.Contents[0].GetType().Name}");
{
"role": "assistant",
"contents": [
{
"$type": "functionCall",
"name": "get_order_status",
"arguments": {
"orderId": 1042
},
"informationalOnly": false,
"callId": "call_1"
}
]
}
-> assistant: FunctionCallContent
{
"role": "tool",
"contents": [
{
"$type": "functionResult",
"result": {
"status": "Shipped",
"carrier": "UPS"
},
"callId": "call_1"
}
]
}
-> tool: FunctionResultContent
{"AuthorName":null,"CreatedAt":null,"Role":"assistant","Contents":[{"$type":"functionCall","Name":"get_order_status","Arguments":{"orderId":1042},"InformationalOnly":false,"CallId":"call_1","Annotations":null,"AdditionalProperties":null}],"MessageId":null,"AdditionalProperties":null}
-> assistant: FunctionCallContent
Content'in alt tipi $type alanıyla yazılıyor ve geri okurken doğru class'a dönüyor. Varsayılan ayarlar da tipi koruyor, çünkü polymorphism bilgisi AIContent'in üzerinde attribute olarak duruyor; ama alan adları PascalCase, null'lar da yazılıyor. DefaultOptions camelCase kullanıyor ve null'ları atlıyor. Bir ayrıntı daha: çıktı girintili (indented). Ekranda okumak için iyi, veritabanında her satırda boşluk karakterleri demek. Bu yüzden veritabanı için girintisiz bir kopya kullanacağız.
EF Core Modeli: Conversation ve StoredMessage
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Assistant.Api.Data;
public sealed class Conversation
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string UserId { get; init; }
// Stateful provider'larda (ör. OpenAI Responses) geçmişi provider tutar; burada yalnız id'si durur
public string? ProviderConversationId { get; set; }
public List<StoredMessage> Messages { get; init; } = [];
}
public sealed class StoredMessage
{
public long Id { get; init; }
public Guid ConversationId { get; init; }
public required string Role { get; init; }
// ChatMessage'ın tamamı: text, tool call ve tool result content'leri dahil
public required string Json { get; init; }
}
public sealed class ChatDbContext(DbContextOptions<ChatDbContext> options) : DbContext(options)
{
public DbSet<Conversation> Conversations => Set<Conversation>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Conversation>().HasIndex(c => c.UserId);
modelBuilder.Entity<Conversation>()
.HasMany(c => c.Messages)
.WithOne()
.HasForeignKey(m => m.ConversationId);
}
}
Her mesaj bir satır, içeriği bir JSON kolonu. ChatMessage'ın yapısını ilişkisel tablolara bölmeye (content tipi başına tablo gibi) değmez: MEAI yeni content tipleri eklemeye devam ediyor ve geçmiş hep bütün olarak okunuyor. Role'ü ayrı kolon olarak tutmak sorgu ve raporlama için yeterli. Sıralamayı Id ile yapıyorum; SQLite provider'ı DateTimeOffset üzerinde ORDER BY çeviremiyor ve artan anahtar zaten ekleme sırasını veriyor. EF (Entity Framework) Core serisinin "EF Core 7 Yenilikleri" yazısında anlattığımız JSON kolon eşlemesi de bir seçenek, ama burada JSON'u EF Core değil MEAI'nin serializer'ı ürettiği için düz bir string kolon daha az sürpriz çıkarıyor.
JSON dönüşümü tek bir yerde:
using System.Text.Json;
using Bookstore.Assistant.Api.Data;
using Microsoft.Extensions.AI;
namespace Bookstore.Assistant.Api;
public static class ChatHistory
{
// AIContent alt tiplerini ($type) bilen hazır ayarlar; veritabanı için girintisiz kopyası
private static readonly JsonSerializerOptions Json = new(AIJsonUtilities.DefaultOptions) { WriteIndented = false };
public static List<ChatMessage> Load(Conversation conversation) =>
[.. conversation.Messages.OrderBy(m => m.Id).Select(m => JsonSerializer.Deserialize<ChatMessage>(m.Json, Json)!)];
public static void Append(Conversation conversation, IEnumerable<ChatMessage> messages)
{
foreach (ChatMessage message in messages)
{
// Reasoning (thinking) metnini saklama: uzun, ve sonraki turda modele geri gitmesi gerekmiyor
List<AIContent> contents = [.. message.Contents.Where(c => c is not TextReasoningContent)];
if (contents.Count == 0)
{
continue;
}
ChatMessage stored = new(message.Role, contents) { AuthorName = message.AuthorName, MessageId = message.MessageId };
conversation.Messages.Add(new StoredMessage
{
Role = stored.Role.Value,
Json = JsonSerializer.Serialize(stored, Json),
});
}
}
}
Bu yazıda model qwen3:4b, yani bir reasoning modeli. OllamaSharp modelin düşünme metnini cevaba bir TextReasoningContent olarak koyuyor ve 7. yazıda göreceğimiz gibi bu metin tek cümlelik bir cevap için bile yüzlerce token tutabiliyor. Onu saklamıyorum: veritabanını şişirir, sonraki turda modele geri göndermenin bir faydası yok ve kullanıcıya gösterilmeyen bir metnin kişisel veri içerip içermediğini denetlemek de ayrı bir yük. Yalnız reasoning'den oluşan bir mesaj hiç yazılmıyor.
Endpoint: Yükle, Gönder, Sakla
app.MapPost("/conversations/{id:guid}/messages", async (
Guid id, SendMessageRequest request, HttpContext http, ChatDbContext db, IChatClient chat, CancellationToken ct) =>
{
// Sahiplik kontrolü sorgunun içinde: başkasının conversation'ı "yok" kabul edilir
string userId = CurrentUser(http);
Conversation? conversation = await db.Conversations
.Include(c => c.Messages)
.SingleOrDefaultAsync(c => c.Id == id && c.UserId == userId, ct);
if (conversation is null)
{
return Results.NotFound();
}
ChatMessage userMessage = new(ChatRole.User, request.Text);
ChatOptions options = new() { ConversationId = conversation.ProviderConversationId };
// Stateless provider: bütün geçmiş gider. Stateful provider: yalnız yeni mesaj gider, geçmişi provider tutar.
List<ChatMessage> toSend = conversation.ProviderConversationId is null
? [new(ChatRole.System, SystemPrompt), .. ChatHistory.Load(conversation), userMessage]
: [userMessage];
ChatResponse response = await chat.GetResponseAsync(toSend, options, ct);
conversation.ProviderConversationId = response.ConversationId;
ChatHistory.Append(conversation, [userMessage, .. response.Messages]);
await db.SaveChangesAsync(ct);
return Results.Ok(new
{
answer = response.Text,
sentMessages = toSend.Count,
storedMessages = conversation.Messages.Count,
providerConversationId = response.ConversationId,
});
});
System prompt veritabanına yazılmıyor, her istekte en başa ekleniyor. Böylece prompt'u değiştirdiğinde eski sohbetler de yeni prompt'la devam eder. Kaydedilen şey kullanıcı mesajı ve response.Messages'ın tamamı; response.Text değil. Pipeline'da function invocation olsaydı response.Messages tool call ve tool result mesajlarını da içerirdi ve hepsi aynı çağrıyla saklanırdı.
Pipeline şöyle kurulu:
IChatClient ollama = new OllamaApiClient(
new HttpClient { BaseAddress = new Uri("http://localhost:11434"), Timeout = TimeSpan.FromMinutes(30) }, "qwen3:4b");
#pragma warning disable MEAI001 // chat reducer'lar experimental
builder.Services.AddChatClient(ollama)
.UseChatReducer(new MessageCountingChatReducer(20));
#pragma warning restore MEAI001
Açılıştaki senaryo, iki tur:
$ curl -s -X POST localhost:19623/conversations -H "X-Demo-User: ada"
{"id":"706332a3-37d8-4b1b-8873-c0eebe6d7e2a"}
$ POST /conversations/706332a3-37d8-4b1b-8873-c0eebe6d7e2a/messages (ada) Hi, I'm Ada...
{"answer":"Welcome, Ada! If you're into software design, I'd recommend *Clean Code* by Robert C. Martin for practical principles and *Design Patterns* by Gamma et al. for reusable solutions.","sentMessages":2,"storedMessages":2,"providerConversationId":null}
$ POST /conversations/706332a3-37d8-4b1b-8873-c0eebe6d7e2a/messages (ada) What is my name...
{"answer":"Your name is Ada, and you enjoy software design books.","sentMessages":4,"storedMessages":4,"providerConversationId":null}
$ GET /conversations/706332a3-37d8-4b1b-8873-c0eebe6d7e2a/messages (ada)
[{"role":"user","json":"{\"role\":\"user\",\"contents\":[{\"$type\":\"text\",\"text\":\"Hi, I am Ada. I only read books about software design.\"}]}"},{"role":"assistant","json":"{\"role\":\"assistant\",\"contents\":[{\"$type\":\"text\",\"text\":\"Welcome, Ada! If you're into software design, I'd recommend *Clean Code* by Robert C. Martin for practical principles and *Design Patterns* by Gamma et al. for reusable solutions.\"}],\"messageId\":\"2026-11-08T08:06:33.367066Z\"}"},{"role":"user","json":"{\"role\":\"user\",\"contents\":[{\"$type\":\"text\",\"text\":\"What is my name, and which kind of books do I like?\"}]}"},{"role":"assistant","json":"{\"role\":\"assistant\",\"contents\":[{\"$type\":\"text\",\"text\":\"Your name is Ada, and you enjoy software design books.\"}],\"messageId\":\"2026-11-08T08:07:22.39111Z\"}"}]
$ POST /conversations/706332a3-37d8-4b1b-8873-c0eebe6d7e2a/messages (grace)
HTTP/1.1 404 Not Found
$ sqlite
1|user|109
2|assistant|281
3|user|106
4|assistant|155
İlk turda modele iki mesaj gitti (system prompt ve Ada'nın mesajı), ikinci turda dört: system prompt, veritabanından yüklenen iki mesaj ve yeni soru. Model Ada'nın adını ve ilgi alanını bildi, çünkü onları ikinci istekte biz geri gönderdik. Açılıştaki ilk sorunun cevabı bu: hafıza ne modelde ne Ollama'da, senin veritabanında. Veritabanında dört satır var ve hiçbirinde düşünme metni yok; Append'deki filtre çalışıyor. Aynı conversation id'siyle gelen grace ise 404 aldı. Asistan mesajlarındaki messageId alanını OllamaSharp dolduruyor (Ollama'nın cevap zamanı); onu da olduğu gibi saklıyoruz.
Authorization: Başkasının Sohbeti
Conversation id'si URL'de (Uniform Resource Locator) dolaşıyor. Bir GUID (Globally Unique Identifier) tahmin edilmesi zor bir değer ama bir sır değil: log'lara, tarayıcı geçmişine, destek ekibine giden ekran görüntülerine düşer. Web Güvenliği (OWASP Top 10) serisindeki Broken Access Control konusunun bu senaryodaki karşılığı şu: id'yi bilen herkes sohbeti okuyabilmemeli. Sahiplik kontrolünü sorgunun içine koydum (c.Id == id && c.UserId == userId); başka bir kullanıcının isteği için kayıt hiç bulunmuyor ve cevap 404, 403 değil. 403 "böyle bir sohbet var ama senin değil" bilgisini sızdırır.
Bu sorguyu yazarken bir derleyici hatası da aldım. İlk hâlinde lambda'nın içinde doğrudan local fonksiyonu çağırıyordum:
.SingleOrDefaultAsync(c => c.Id == id && c.UserId == CurrentUser(http), ct);
error CS8110: An expression tree may not contain a reference to a local function
EF Core sorgusu bir expression tree; içinde bir local fonksiyona referans olamaz. Kullanıcı id'sini önce bir değişkene almak hem hatayı çözüyor hem de sorguyu SQL'e (Structured Query Language) çevrilebilir bir parametreyle bırakıyor. Örnekte kullanıcıyı bir başlıktan okuyorum; gerçek uygulamada bu değer authentication'dan gelen claim'dir (Authentication ve Authorization serisi).
Stateful Provider'lar: ConversationId
Bazı provider'lar geçmişi kendileri tutar; OpenAI'ın Responses API'si bunun bilinen örneği. Bu durumda yalnız yeni mesajı gönderirsin ve sohbeti bir id ile işaret edersin. MEAI bunu iki property ile soyutluyor: istekte ChatOptions.ConversationId, cevapta ChatResponse.ConversationId. Yukarıdaki endpoint ikisini birlikte destekliyor: cevapta bir id gelirse onu saklıyor ve sonraki turda geçmişi göndermeden yalnız yeni mesajı gönderiyor; gelmezse bütün geçmişi gönderiyor. Microsoft Learn'deki IChatClient dokümanının önerdiği yaklaşım da bu: provider'ın stateful olup olmadığını önceden bilmiyorsan cevaptaki ConversationId'ye bak.
Ollama stateless; çıktıdaki providerConversationId her iki turda da null. Stateful bir provider'la bu kod bu yazıda çalıştırılmadı. Yine de mesajları kendi veritabanında saklamaya devam etmeni öneririm: provider'daki sohbetin saklama süresi provider'ın kararıdır, denetim ve destek için ise kendi kaydın gerekir.
Context Window Dolarken: Chat Reducer'lar
Her tur bütün geçmişi gönderdiği için istek büyüdükçe büyür. LLM Temelleri'nde gördüğümüz gibi context window dolduğunda model en eski kısmı sessizce keser ve her ek token'ın maliyeti var. MEAI bunun için IChatReducer abstraction'ını ve iki hazır implementasyon sunuyor. İkisi de experimental (MEAI001 uyarısı); API'leri değişebilir.
Açılıştaki ikinci soruyu bir console uygulamasında denedim (Print her mesajı rolü ve content'leriyle tek satırda yazan küçük bir yardımcı):
List<ChatMessage> history =
[
new(ChatRole.System, "You are the Bookstore assistant."),
new(ChatRole.User, "Hi, I'm Ada. I only read books about software design."),
new(ChatRole.Assistant, "Nice to meet you, Ada! How can I help?"),
new(ChatRole.User, "Where is my order 1042?"),
new(ChatRole.Assistant, [new FunctionCallContent("call_1", "get_order_status", new Dictionary<string, object?> { ["orderId"] = 1042 })]),
new(ChatRole.Tool, [new FunctionResultContent("call_1", new { status = "Shipped", carrier = "UPS" })]),
new(ChatRole.Assistant, "Order 1042 has shipped with UPS."),
new(ChatRole.User, "Recommend me a book for the weekend."),
];
#pragma warning disable MEAI001 // chat reducer'lar experimental
IChatReducer counting = new MessageCountingChatReducer(4);
Print("MessageCountingChatReducer(4):", await counting.ReduceAsync(history, CancellationToken.None));
IChatClient summarizer = new OllamaApiClient(new HttpClient { BaseAddress = new Uri("http://localhost:11434"), Timeout = TimeSpan.FromMinutes(30) }, "qwen3:4b");
IChatReducer summarizing = new SummarizingChatReducer(summarizer, targetCount: 2, threshold: 1);
Print("SummarizingChatReducer(target 2, threshold 1):", await summarizing.ReduceAsync(history, CancellationToken.None));
#pragma warning restore MEAI001
MessageCountingChatReducer(4):
system "You are the Bookstore assistant."
assistant "Nice to meet you, Ada! How can I help?"
user "Where is my order 1042?"
assistant "Order 1042 has shipped with UPS."
user "Recommend me a book for the weekend."
SummarizingChatReducer(target 2, threshold 1):
system "You are the Bookstore assistant."
assistant "Ada mentioned she only reads software design books and then asked for the location of her order 1042. The assistant greeted her and asked how they could help."
assistant [call call_1 get_order_status]
tool [result call_1]
assistant "Order 1042 has shipped with UPS."
user "Recommend me a book for the weekend."
İki reducer tam tersi davranıyor. MessageCountingChatReducer(4) system prompt'u tuttu ve son dört mesajı bıraktı, ama sayarken tool call ve tool result mesajlarını hiç hesaba katmadı: ikisi de listeden tamamen çıktı. Dokümantasyonu da bunu söylüyor: function call ya da function result içeren mesajlar indirgenmiş çıktıya alınmaz. Ada'nın "yalnız yazılım tasarımı kitapları okurum" mesajı da gitti; hafta sonu için kitap önerisi isteyen son soru artık o bilgi olmadan cevaplanacak. SummarizingChatReducer ise eski mesajların özetini modele çıkarttı ve özeti tek bir asistan mesajı olarak başa koydu; Ada'nın tercihi özette korunmuş. Tool call ve tool result mesajlarına dokunmadı, onları özete katmadan olduğu gibi bıraktı. Bedeli de var: özet çıkarmak bir model çağrısı demek, yani ek gecikme ve ek token.
Reducer'ı pipeline'a UseChatReducer ile koyduğunda kırpma yalnız modele giden listeye uygulanır; veritabanındaki geçmiş eksiksiz kalır. Doğru ayrım bu: saklama tam, gönderim kırpılmış. Kırpılmış bir listeyi geri veritabanına yazarsan kaybettiğin bilgiyi bir daha bulamazsın.
Üretimde Dikkat
Sohbetlerin bir saklama politikası olsun. Sohbet geçmişi kişisel veridir: kullanıcının adı, siparişleri, bazen sağlık ya da finans bilgisi. KVKK (Kişisel Verilerin Korunması Kanunu) ve GDPR (General Data Protection Regulation) gibi düzenlemeler verinin ne kadar süre ve hangi amaçla tutulduğunu sorar. Belirli bir süreden eski conversation'ları silen bir background job ve kullanıcıya "geçmişimi sil" seçeneği baştan tasarlanmalı.
Büyümeyi ölç. Her tur için gönderilen mesaj sayısını ve Usage.InputTokenCount'u log'la. Input token'ının tur başına nasıl arttığını görmek, reducer sınırını nereye koyacağını tahminle değil veriyle belirlemeni sağlar.
Eşzamanlılığı düşün. Aynı conversation'a iki sekmeden aynı anda mesaj gelirse ikisi de aynı geçmişi okur ve cevaplarını arka arkaya ekler; geçmişte iki cevap yan yana durur. Basit bir çözüm conversation satırına bir concurrency token (EF Core'da [ConcurrencyCheck] ya da rowversion) koyup çakışmada ikinci isteği reddetmek.
Sık Düşülen Tuzaklar
Birincisi, başka kullanıcının conversation id'si ile geçmiş okumak. Sahiplik kontrolü ayrı bir if'te değil sorgunun içinde olsun ve cevap 404 olsun.
İkincisi, yalnız response.Text'i saklamak. Tool call ve tool result mesajları kaybolur; bir sonraki turda model "bu bilgiyi nereden biliyordum?" durumuna düşer, aynı tool'u yeniden çağırır ya da uydurur. response.Messages'ın tamamını sakla.
Üçüncüsü, sınırsız büyüyen geçmiş. Reducer koymadığın her sohbet bir gün context window'u doldurur. Bunun hata olarak görünmemesi işi zorlaştırır: model eski mesajları sessizce keser ve kullanıcı "az önce söylemiştim" der.
Dördüncüsü, reducer'ın tool mesajlarını attığını bilmemek. MessageCountingChatReducer function call ve function result içeren mesajları tamamen dışarıda bırakır; sayım sınırına bakmadan. Tool sonucuna dayanan bir bilgi ancak onu kullanıcıya aktaran asistan cevabı listede kaldıysa modele ulaşır.
Küçük Bir Deneme
★☆☆ Endpoint'in cevabına inputTokens = response.Usage?.InputTokenCount ekle ve aynı sohbette beş tur konuş. Input token sayısı tur başına nasıl büyüyor?
★★☆ MessageCountingChatReducer(20)'yi 2'ye indir ve açılıştaki iki turlu sohbeti tekrar çalıştır. Model Ada'nın adını hâlâ biliyor mu? Bilmiyorsa, kullanıcının adı gibi kalıcı bilgiyi geçmişten bağımsız nerede tutardın?
★★★ Conversation'a bir Summary kolonu ekle: geçmiş 20 mesajı geçtiğinde SummarizingChatReducer ile eski kısmın özetini çıkar, özeti sakla ve sonraki isteklerde system prompt'tan hemen sonra gönder. Özet çıkarma isteğini kullanıcının cevabını bekletmeden nasıl yaparsın?
Asistan artık hatırlıyor. Bir sonraki yazıda ona iş yaptırıyoruz: LLM Temelleri'nde mantığını gördüğümüz tool calling'i production'a taşımak, FunctionInvokingChatClient ayarları, DI'dan (Dependency Injection) gelen scoped servisleri kullanan tool'lar ve sipariş iptali gibi geri alınamaz işlemler için HTTP (Hypertext Transfer Protocol) üzerinden yürüyen bir onay akışı.