Skip to content

Commit 09f94be

Browse files
franklinicclaude
andcommitted
feat(agentic): Phase 3 — incremental (KV-cache) decoding seam + engine fast path (#1544)
- IIncrementalCausalLanguageModel<T> : ICausalLanguageModel<T> adds ResetCache / StartSequence(prompt) / AppendToken(id) — the contract a KV-cached model implements to advance one token at a time instead of re-feeding the full O(n^2) context. - LocalEngineChatClient auto-detects the interface and takes the incremental fast path (prime once, then feed single tokens), falling back to full re-feed otherwise. Constraint + stop-sequence + sampler logic shared via a single PickToken helper across both paths. - 2 tests (green net10.0 + net471): the engine drives the incremental path correctly (ResetCache once, StartSequence with the prompt, AppendToken per generated token) and produces output IDENTICAL to the full-refeed path. The remaining half — a real K/V cache inside Mamba/GLA/attention forward — is a model-layer change (the network Predict API has no incremental entry point yet); this lands the engine-side support + verification it plugs into. Part of epic #1544 (Phase 3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d3284f6 commit 09f94be

3 files changed

Lines changed: 260 additions & 6 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using AiDotNet.LinearAlgebra;
2+
3+
namespace AiDotNet.Agentic.Models.Local;
4+
5+
/// <summary>
6+
/// An <see cref="ICausalLanguageModel{T}"/> that supports incremental (KV-cached) decoding: it processes the
7+
/// prompt once, caches the per-position state, and then advances one token at a time without recomputing the
8+
/// whole sequence. This is the fast path for autoregressive generation.
9+
/// </summary>
10+
/// <typeparam name="T">The tensor element type.</typeparam>
11+
/// <remarks>
12+
/// <para>
13+
/// The base <see cref="ICausalLanguageModel{T}.NextTokenLogits"/> re-feeds the full context each step, which
14+
/// is correct but O(n²) over a generation. A model that maintains a key/value cache implements this interface
15+
/// so <see cref="LocalEngineChatClient{T}"/> can drive it incrementally: <see cref="StartSequence"/> primes
16+
/// the cache with the prompt and returns the first next-token logits, then each <see cref="AppendToken"/>
17+
/// feeds a single new token and returns the following logits. <see cref="ResetCache"/> clears state between
18+
/// independent generations.
19+
/// </para>
20+
/// <para><b>For Beginners:</b> Without caching, predicting each new word re-reads the entire conversation —
21+
/// slower and slower as it grows. With caching, the model remembers the work it already did and only looks at
22+
/// the one new word each time. This interface is how a model advertises "I can do the fast, remember-as-you-go
23+
/// version"; the engine uses it automatically when available and falls back to the simple way otherwise.
24+
/// </para>
25+
/// </remarks>
26+
public interface IIncrementalCausalLanguageModel<T> : ICausalLanguageModel<T>
27+
{
28+
/// <summary>
29+
/// Clears any cached decoding state, so the next <see cref="StartSequence"/> begins fresh.
30+
/// </summary>
31+
void ResetCache();
32+
33+
/// <summary>
34+
/// Processes the prompt, populating the cache, and returns the logits for the first generated token.
35+
/// </summary>
36+
/// <param name="promptTokenIds">The prompt token ids. Must be non-empty.</param>
37+
/// <returns>The next-token logits following the prompt (length = <see cref="ICausalLanguageModel{T}.VocabularySize"/>).</returns>
38+
Vector<T> StartSequence(IReadOnlyList<int> promptTokenIds);
39+
40+
/// <summary>
41+
/// Appends a single token to the cached context and returns the logits for the token after it.
42+
/// </summary>
43+
/// <param name="tokenId">The token id to append (typically the one just generated).</param>
44+
/// <returns>The next-token logits (length = <see cref="ICausalLanguageModel{T}.VocabularySize"/>).</returns>
45+
Vector<T> AppendToken(int tokenId);
46+
}

src/Agentic/Models/Local/LocalEngineChatClient.cs

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ public Task<ChatResponse> GetResponseAsync(
8686
var sampling = ResolveSampling(options);
8787
var sampler = new TokenSampler<T>(sampling.Seed);
8888
generated = new List<int>();
89-
finishReason = RunGeneration(promptIds, maxTokens, sampler, sampling, stopSequences, generated, cancellationToken);
89+
finishReason = _model is IIncrementalCausalLanguageModel<T> incremental
90+
? RunGenerationIncremental(incremental, promptIds, maxTokens, sampler, sampling, stopSequences, generated, cancellationToken)
91+
: RunGeneration(promptIds, maxTokens, sampler, sampling, stopSequences, generated, cancellationToken);
9092
}
9193

9294
var text = generated.Count > 0 ? _tokenizer.Decode(generated) : string.Empty;
@@ -182,11 +184,51 @@ private ChatFinishReason RunGeneration(
182184
return ChatFinishReason.Length;
183185
}
184186

185-
// Computes the next token id honoring the configured constraint, or null when the constraint signals a
186-
// terminal state (no valid continuation exists).
187-
private int? NextToken(
188-
List<int> context,
187+
// Incremental (KV-cached) generation: prime with the prompt, then advance one token at a time.
188+
private ChatFinishReason RunGenerationIncremental(
189+
IIncrementalCausalLanguageModel<T> model,
190+
IReadOnlyList<int> promptIds,
191+
int maxTokens,
192+
TokenSampler<T> sampler,
193+
LocalSamplingOptions sampling,
194+
IReadOnlyList<string>? stopSequences,
189195
List<int> generated,
196+
CancellationToken cancellationToken)
197+
{
198+
model.ResetCache();
199+
var logits = model.StartSequence(promptIds);
200+
201+
for (var step = 0; step < maxTokens; step++)
202+
{
203+
cancellationToken.ThrowIfCancellationRequested();
204+
205+
var next = PickToken(generated, logits, sampler, sampling);
206+
if (next is null || next.Value == _tokenizer.EosTokenId)
207+
{
208+
return ChatFinishReason.Stop;
209+
}
210+
211+
generated.Add(next.Value);
212+
213+
if (stopSequences is not null && ContainsStopSequence(_tokenizer.Decode(generated), stopSequences))
214+
{
215+
return ChatFinishReason.Stop;
216+
}
217+
218+
if (step < maxTokens - 1)
219+
{
220+
logits = model.AppendToken(next.Value);
221+
}
222+
}
223+
224+
return ChatFinishReason.Length;
225+
}
226+
227+
// Computes the next token id from precomputed logits, honoring the configured constraint, or null when
228+
// the constraint signals a terminal state (no valid continuation exists).
229+
private int? PickToken(
230+
List<int> generated,
231+
Vector<T> logits,
190232
TokenSampler<T> sampler,
191233
LocalSamplingOptions sampling)
192234
{
@@ -200,10 +242,17 @@ private ChatFinishReason RunGeneration(
200242
}
201243
}
202244

203-
var logits = _model.NextTokenLogits(context);
204245
return sampler.Sample(logits, sampling, allowed);
205246
}
206247

248+
// Computes the next token id by re-feeding the full context (fallback when the model has no KV-cache).
249+
private int? NextToken(
250+
List<int> context,
251+
List<int> generated,
252+
TokenSampler<T> sampler,
253+
LocalSamplingOptions sampling) =>
254+
PickToken(generated, _model.NextTokenLogits(context), sampler, sampling);
255+
207256
// Beam search: explore `beamWidth` hypotheses in parallel, expanding each by its top tokens (honoring
208257
// the constraint), and keep the highest-scoring (length-normalized log-probability) beams. Returns the
209258
// best completion. Deterministic.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using AiDotNet.Agentic.Models;
6+
using AiDotNet.Agentic.Models.Local;
7+
using AiDotNet.LinearAlgebra;
8+
using Xunit;
9+
10+
namespace AiDotNetTests.UnitTests.Agentic.Local
11+
{
12+
public class IncrementalDecodingTests
13+
{
14+
// Deterministic next-token rule keyed on the last token: 0(EOS)->1, 1->2, 2->3, 3->0(EOS).
15+
private static readonly Dictionary<int, int> Transition = new()
16+
{
17+
[0] = 1,
18+
[1] = 2,
19+
[2] = 3,
20+
[3] = 0,
21+
};
22+
23+
private const int Vocab = 4;
24+
25+
private static MapTokenizer BuildTokenizer() => new(eos: 0, ("A", 1), ("B", 2), ("C", 3));
26+
27+
private static double[] LogitsFor(int lastToken)
28+
{
29+
var next = Transition.TryGetValue(lastToken, out var n) ? n : 0;
30+
var logits = new double[Vocab];
31+
logits[next] = 10.0;
32+
return logits;
33+
}
34+
35+
[Fact(Timeout = 60000)]
36+
public async Task Engine_UsesIncrementalPath_FeedingOneTokenAtATime()
37+
{
38+
var model = new IncrementalProbe();
39+
var engine = new LocalEngineChatClient<double>(model, BuildTokenizer(), options: new LocalEngineOptions
40+
{
41+
Sampling = new LocalSamplingOptions { Temperature = 0.0 },
42+
});
43+
44+
var response = await engine.GetResponseAsync(new[] { ChatMessage.User("go") });
45+
46+
Assert.Equal("ABC", response.Text);
47+
Assert.Equal(ChatFinishReason.Stop, response.FinishReason);
48+
49+
// The engine took the incremental fast path: reset once, primed once with the prompt, then fed
50+
// each generated token singly via AppendToken.
51+
Assert.Equal(1, model.ResetCalls);
52+
Assert.Equal(1, model.StartCalls);
53+
Assert.Equal(new[] { 1, 2, 3 }, model.AppendedTokens);
54+
}
55+
56+
[Fact(Timeout = 60000)]
57+
public async Task IncrementalAndFullRefeed_ProduceIdenticalOutput()
58+
{
59+
var tokenizer = BuildTokenizer();
60+
var sampling = new LocalSamplingOptions { Temperature = 0.0 };
61+
62+
var incremental = new LocalEngineChatClient<double>(new IncrementalProbe(), tokenizer,
63+
options: new LocalEngineOptions { Sampling = sampling });
64+
var fullRefeed = new LocalEngineChatClient<double>(new FullRefeedModel(), tokenizer,
65+
options: new LocalEngineOptions { Sampling = sampling });
66+
67+
var a = await incremental.GetResponseAsync(new[] { ChatMessage.User("go") });
68+
var b = await fullRefeed.GetResponseAsync(new[] { ChatMessage.User("go") });
69+
70+
Assert.Equal(b.Text, a.Text);
71+
Assert.Equal("ABC", a.Text);
72+
}
73+
74+
// ---- Test doubles ----
75+
76+
// A KV-cached model: tracks how the engine drives it and advances state by the last token only.
77+
private sealed class IncrementalProbe : IIncrementalCausalLanguageModel<double>
78+
{
79+
private int _lastToken;
80+
81+
public int VocabularySize => Vocab;
82+
83+
public int ResetCalls { get; private set; }
84+
85+
public int StartCalls { get; private set; }
86+
87+
public List<int> AppendedTokens { get; } = new();
88+
89+
public void ResetCache()
90+
{
91+
ResetCalls++;
92+
_lastToken = 0;
93+
}
94+
95+
public Vector<double> StartSequence(IReadOnlyList<int> promptTokenIds)
96+
{
97+
StartCalls++;
98+
_lastToken = promptTokenIds[promptTokenIds.Count - 1];
99+
return new Vector<double>(LogitsFor(_lastToken));
100+
}
101+
102+
public Vector<double> AppendToken(int tokenId)
103+
{
104+
AppendedTokens.Add(tokenId);
105+
_lastToken = tokenId;
106+
return new Vector<double>(LogitsFor(_lastToken));
107+
}
108+
109+
// Full-refeed fallback (unused when the engine takes the incremental path).
110+
public Vector<double> NextTokenLogits(IReadOnlyList<int> tokenIds) =>
111+
new(LogitsFor(tokenIds[tokenIds.Count - 1]));
112+
}
113+
114+
// Same next-token rule, but no KV-cache: forces the engine's full-context path.
115+
private sealed class FullRefeedModel : ICausalLanguageModel<double>
116+
{
117+
public int VocabularySize => Vocab;
118+
119+
public Vector<double> NextTokenLogits(IReadOnlyList<int> tokenIds) =>
120+
new(LogitsFor(tokenIds[tokenIds.Count - 1]));
121+
}
122+
123+
private sealed class MapTokenizer : IGenerationTokenizer
124+
{
125+
private readonly Dictionary<string, int> _wordToId = new(StringComparer.Ordinal);
126+
private readonly Dictionary<int, string> _idToWord = new();
127+
128+
public MapTokenizer(int eos, params (string Word, int Id)[] entries)
129+
{
130+
EosTokenId = eos;
131+
_idToWord[eos] = string.Empty;
132+
foreach (var (word, id) in entries)
133+
{
134+
_wordToId[word] = id;
135+
_idToWord[id] = word;
136+
}
137+
}
138+
139+
public int EosTokenId { get; }
140+
141+
public IReadOnlyList<int> Encode(string text)
142+
{
143+
var ids = text
144+
.Split(new[] { ' ', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries)
145+
.Select(w => _wordToId.TryGetValue(w, out var id) ? id : EosTokenId)
146+
.ToList();
147+
if (ids.Count == 0)
148+
{
149+
ids.Add(EosTokenId);
150+
}
151+
152+
return ids;
153+
}
154+
155+
public string Decode(IReadOnlyList<int> tokenIds) =>
156+
string.Concat(tokenIds.Select(id => _idToWord.TryGetValue(id, out var w) ? w : string.Empty));
157+
}
158+
}
159+
}

0 commit comments

Comments
 (0)