Ordre 20260912T193441Z-7358817909. Steg 1 var ikke transformen, men å rette
roadmapens R13-gate og få den ratifisert. Gaten `grep -rl "Cosmo"
skills/*/references -> 0` var usann på to uavhengige måter:
1. Ordren fanget den første: 451 av forekomstene er Azure Cosmos DB, ekte
produktinnhold. Diskriminatoren er ikke bokstaven «s» — `Cosmos <norsk
substantiv>` er genitiv av personaen (`### Cosmos tonalitet`), mens
`Cosmos DB`/`CosmosClient`/`cosmos_ru` er produkt.
2. Denne økten fant den andre: 132 persona-forekomster ligger i prosa,
tabeller, dialog-replikker og proveniens-linjer. Heading-nøytralisering
kan ikke nå dem, så «0 persona» er uoppnåelig også under den ratifiserte
formen. Operatøren ratifiserte alternativ A: gaten speiler formen, og de
132 bokføres til R13b/R14.
Tre korreksjoner av premisser som sto i ordren og STATE:
«ca 320 produkt» -> 451 (case-sensitivt nett manglet 327 lowercase
TOC-ankre + 99 identifikatorer; sann nevner 1 638)
«169 headinger» -> 401. 169 var `^## For Cosmo`-prefikset (168) og var
internt inkonsistent med sin egen topp-variant (204)
«417 matcher ingen
populasjon» -> 417 er cosmo-headinger utenfor kodefences; briefens
nevner var reell hele tiden
Fence-bevissthet er målt skadelig, ikke nødvendig: begge toggle-regler er
gale på dette korpuset (naiv toggle skjuler en ekte heading i
chain-of-thought-prompting.md, CommonMark-regelen ubalanserer
service-level-documentation-dr.md). Fence-agnostisk deteksjon finner 401
heading-linjer i nøyaktig de samme 40 variantene som fence-bevisst finner
400 i — ingen kodeblokk-linje er byte-identisk til en persona-heading. Derfor
nøkles transformen på 40 enumererte heading-tekster og ignorerer fences. En
ukjent variant kaster; en slug-kollisjon kaster. Ingenting auto-fikses.
TOC-en regenereres ikke, den rettes kirurgisk: alle 327 persona-lenker hadde
lenketekst lik én av de 40 heading-tekstene og anker lik slugify av den
(327/327, 0 avvik), så heading og TOC-entry skrives i samme operasjon og
ingen mellomtilstand etterlater en død lenke.
Ratifisert målform: `For Cosmo`, `For Cosmo Skyberg` og `For arkitekten
(Cosmo)` konvergerer på `For arkitekten`. To filer kolliderte og er adjudisert
ved å lese dem, ikke ved regel.
Verifisering (alle 7 kriterier fra ordren):
G1 persona på heading-linjer 401 -> 0
G2 døde fragmentlenker 1 -> 1 (pre-eksisterende, unntatt)
G3 produkt-forekomster 451 -> 451; `Cosmos DB|Azure Cosmos` 308 = 308
de 3 kun-produkt-filene byte-identiske
nettet validert begge veier injisert persona feller G1; genitiv feller G1;
produkt-heading og de 3 filene passerer
hele diffen 802 heading-linjer + 654 TOC-linjer, ANNET = 0
linjeantall 728 lagt til = 728 slettet
suite 1120/1120 (1097 + 23 nye)
validate-plugin 250 PASS / 0 FAIL
stikkprøve 10 filer, alle 5 skills, inkl. de 3 mest
produkt-tunge (26/20/19) — kun heading+TOC
Utenfor scope, urørt: de 4 SKILL.md, de 23 commands, CLAUDE.md, README.md,
NOTICE.md, docs/ (alt R14).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
493 lines
17 KiB
Markdown
493 lines
17 KiB
Markdown
# Response Chunking Strategies
|
|
|
|
**Last updated:** 2026-06-24
|
|
**Status:** GA
|
|
**Category:** Performance & Scalability
|
|
**Type:** reference
|
|
**Source:** https://learn.microsoft.com/azure/application-gateway/use-server-sent-events
|
|
|
|
---
|
|
|
|
## Innhold
|
|
|
|
- [Introduksjon](#introduksjon)
|
|
- [Kjernekomponenter](#kjernekomponenter)
|
|
- [Streaming med Server-Sent Events](#streaming-med-server-sent-events)
|
|
- [Semantic Chunking Approaches](#semantic-chunking-approaches)
|
|
- [Client-Side Reassembly](#client-side-reassembly)
|
|
- [Error Handling in Chunks](#error-handling-in-chunks)
|
|
- [Norsk offentlig sektor](#norsk-offentlig-sektor)
|
|
- [Beslutningsrammeverk](#beslutningsrammeverk)
|
|
- [Referanser](#referanser)
|
|
- [For arkitekten](#for-arkitekten)
|
|
|
|
## Introduksjon
|
|
|
|
Response chunking handler om hvordan store AI-modellresponser fra Azure OpenAI brytes opp og leveres til klienter. Det finnes to hovedtilnærminger: streaming via Server-Sent Events (SSE) der modellens output leveres token-for-token i sanntid, og chunking av store responser der output deles opp i semantisk meningsfulle blokker for videre prosessering.
|
|
|
|
Streaming er den mest brukte chunking-strategien for Azure OpenAI. Når `stream: true` settes i API-kallet, returnerer tjenesten delta-oppdateringer som Server-Sent Events ettersom tokens genereres. Dette gir brukeren umiddelbar feedback (time-to-first-token typisk 200-500ms) i stedet for å vente på hele responsen (som kan ta 5-30 sekunder for lange output). For programmatisk prosessering der hele responsen trengs, er chunking av det endelige resultatet i semantisk koherente blokker viktig for downstream-systemer.
|
|
|
|
For norsk offentlig sektor der AI brukes til å generere lange dokumenter (saksframlegg, utredninger, rapporter), er response chunking avgjørende for å levere god brukeropplevelse og for å kunne prosessere store responser effektivt i saksbehandlingssystemer.
|
|
|
|
## Kjernekomponenter
|
|
|
|
| Komponent | Formål | Teknologi |
|
|
|-----------|--------|-----------|
|
|
| Server-Sent Events (SSE) | Real-time streaming av tokens | HTTP SSE |
|
|
| stream_options | Konfigurer streaming-oppførsel | Azure OpenAI API |
|
|
| Application Gateway | SSE proxy og load balancing | Azure App Gateway |
|
|
| API Management | SSE-støtte med policy-basert routing | Azure APIM |
|
|
| SignalR | Real-time push til web-klienter | Azure SignalR |
|
|
|
|
## Streaming med Server-Sent Events
|
|
|
|
### Python streaming-implementasjon
|
|
|
|
```python
|
|
from openai import AzureOpenAI
|
|
import sys
|
|
|
|
client = AzureOpenAI(
|
|
azure_endpoint="https://my-aoai.openai.azure.com",
|
|
api_key="...",
|
|
api_version="2024-10-21"
|
|
)
|
|
|
|
def stream_chat_completion(messages: list[dict], model: str = "gpt-4o"):
|
|
"""Stream response with real-time token delivery."""
|
|
collected_content = []
|
|
|
|
stream = client.chat.completions.create(
|
|
model=model,
|
|
messages=messages,
|
|
stream=True,
|
|
stream_options={"include_usage": True}, # Få token-bruk til slutt
|
|
max_tokens=2000
|
|
)
|
|
|
|
for chunk in stream:
|
|
if chunk.choices and chunk.choices[0].delta.content:
|
|
token = chunk.choices[0].delta.content
|
|
collected_content.append(token)
|
|
sys.stdout.write(token)
|
|
sys.stdout.flush()
|
|
|
|
# Siste chunk inneholder usage
|
|
if hasattr(chunk, 'usage') and chunk.usage:
|
|
return {
|
|
"content": "".join(collected_content),
|
|
"prompt_tokens": chunk.usage.prompt_tokens,
|
|
"completion_tokens": chunk.usage.completion_tokens,
|
|
"total_tokens": chunk.usage.total_tokens
|
|
}
|
|
|
|
return {"content": "".join(collected_content)}
|
|
|
|
|
|
# Asynkron streaming
|
|
async def async_stream_completion(
|
|
client: AsyncAzureOpenAI,
|
|
messages: list[dict],
|
|
model: str = "gpt-4o",
|
|
on_token: callable = None
|
|
):
|
|
"""Async stream with callback per token."""
|
|
chunks = []
|
|
|
|
async with client.chat.completions.create(
|
|
model=model,
|
|
messages=messages,
|
|
stream=True,
|
|
stream_options={"include_usage": True}
|
|
) as stream:
|
|
async for chunk in stream:
|
|
if chunk.choices and chunk.choices[0].delta.content:
|
|
token = chunk.choices[0].delta.content
|
|
chunks.append(token)
|
|
if on_token:
|
|
await on_token(token)
|
|
|
|
return "".join(chunks)
|
|
```
|
|
|
|
### .NET streaming med IAsyncEnumerable
|
|
|
|
```csharp
|
|
using Azure.AI.OpenAI;
|
|
using OpenAI.Chat;
|
|
|
|
public class StreamingService
|
|
{
|
|
private readonly AzureOpenAIClient _client;
|
|
|
|
public async IAsyncEnumerable<string> StreamCompletionAsync(
|
|
string deploymentName,
|
|
IList<ChatMessage> messages,
|
|
int maxTokens = 2000)
|
|
{
|
|
var chatClient = _client.GetChatClient(deploymentName);
|
|
|
|
var options = new ChatCompletionOptions
|
|
{
|
|
MaxOutputTokenCount = maxTokens
|
|
};
|
|
|
|
// Stream deltas
|
|
await foreach (var update in
|
|
chatClient.CompleteChatStreamingAsync(messages, options))
|
|
{
|
|
foreach (var part in update.ContentUpdate)
|
|
{
|
|
if (!string.IsNullOrEmpty(part.Text))
|
|
{
|
|
yield return part.Text;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bruk i ASP.NET controller
|
|
public async Task StreamToClient(
|
|
HttpContext context,
|
|
string deploymentName,
|
|
IList<ChatMessage> messages)
|
|
{
|
|
context.Response.ContentType = "text/event-stream";
|
|
context.Response.Headers.Append("Cache-Control", "no-cache");
|
|
context.Response.Headers.Append("Connection", "keep-alive");
|
|
|
|
var writer = new StreamWriter(context.Response.Body);
|
|
|
|
await foreach (var token in StreamCompletionAsync(
|
|
deploymentName, messages))
|
|
{
|
|
await writer.WriteAsync($"data: {token}\n\n");
|
|
await writer.FlushAsync();
|
|
}
|
|
|
|
await writer.WriteAsync("data: [DONE]\n\n");
|
|
await writer.FlushAsync();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Semantic Chunking Approaches
|
|
|
|
### Chunk store responser i meningsfulle blokker
|
|
|
|
```python
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
@dataclass
|
|
class SemanticChunk:
|
|
index: int
|
|
content: str
|
|
chunk_type: str # "heading", "paragraph", "code", "list", "table"
|
|
token_count: int
|
|
|
|
def semantic_chunk_response(
|
|
response_text: str,
|
|
max_chunk_tokens: int = 500,
|
|
model: str = "gpt-4o"
|
|
) -> list[SemanticChunk]:
|
|
"""Split AI response into semantically coherent chunks."""
|
|
import tiktoken
|
|
enc = tiktoken.encoding_for_model(model)
|
|
|
|
chunks = []
|
|
current_chunk = []
|
|
current_tokens = 0
|
|
chunk_type = "paragraph"
|
|
|
|
# Del på naturlige grenser
|
|
lines = response_text.split('\n')
|
|
|
|
for line in lines:
|
|
line_tokens = len(enc.encode(line))
|
|
|
|
# Identifiser chunk-type
|
|
if line.startswith('#'):
|
|
chunk_type = "heading"
|
|
elif line.startswith('```'):
|
|
chunk_type = "code"
|
|
elif line.startswith('- ') or line.startswith('* '):
|
|
chunk_type = "list"
|
|
elif line.startswith('|'):
|
|
chunk_type = "table"
|
|
else:
|
|
chunk_type = "paragraph"
|
|
|
|
# Ny chunk ved heading eller ved token-grense
|
|
if (line.startswith('#') and current_chunk) or \
|
|
(current_tokens + line_tokens > max_chunk_tokens and current_chunk):
|
|
chunks.append(SemanticChunk(
|
|
index=len(chunks),
|
|
content='\n'.join(current_chunk),
|
|
chunk_type=chunk_type,
|
|
token_count=current_tokens
|
|
))
|
|
current_chunk = []
|
|
current_tokens = 0
|
|
|
|
current_chunk.append(line)
|
|
current_tokens += line_tokens
|
|
|
|
# Siste chunk
|
|
if current_chunk:
|
|
chunks.append(SemanticChunk(
|
|
index=len(chunks),
|
|
content='\n'.join(current_chunk),
|
|
chunk_type=chunk_type,
|
|
token_count=current_tokens
|
|
))
|
|
|
|
return chunks
|
|
```
|
|
|
|
### Streaming accumulator med chunk-deteksjon
|
|
|
|
```python
|
|
class StreamingChunkAccumulator:
|
|
"""Accumulate streaming tokens into semantic chunks."""
|
|
|
|
def __init__(
|
|
self,
|
|
on_chunk_complete: callable = None,
|
|
chunk_boundary_pattern: str = r'\n#{1,3}\s'
|
|
):
|
|
self.buffer = []
|
|
self.chunks = []
|
|
self.on_chunk_complete = on_chunk_complete
|
|
self.boundary_pattern = re.compile(chunk_boundary_pattern)
|
|
|
|
async def feed_token(self, token: str):
|
|
"""Feed a streaming token to the accumulator."""
|
|
self.buffer.append(token)
|
|
|
|
# Sjekk om vi har nådd en chunk-grense
|
|
current_text = ''.join(self.buffer)
|
|
if self.boundary_pattern.search(current_text):
|
|
# Del på grensen
|
|
parts = self.boundary_pattern.split(current_text, maxsplit=1)
|
|
if len(parts) > 1:
|
|
completed = parts[0]
|
|
remaining = current_text[len(completed):]
|
|
|
|
if completed.strip():
|
|
chunk = SemanticChunk(
|
|
index=len(self.chunks),
|
|
content=completed.strip(),
|
|
chunk_type=self._detect_type(completed),
|
|
token_count=len(completed.split()) # Estimat
|
|
)
|
|
self.chunks.append(chunk)
|
|
|
|
if self.on_chunk_complete:
|
|
await self.on_chunk_complete(chunk)
|
|
|
|
self.buffer = [remaining]
|
|
|
|
def finalize(self) -> list[SemanticChunk]:
|
|
"""Finalize and return all chunks."""
|
|
remaining = ''.join(self.buffer).strip()
|
|
if remaining:
|
|
self.chunks.append(SemanticChunk(
|
|
index=len(self.chunks),
|
|
content=remaining,
|
|
chunk_type=self._detect_type(remaining),
|
|
token_count=len(remaining.split())
|
|
))
|
|
return self.chunks
|
|
|
|
def _detect_type(self, text: str) -> str:
|
|
if text.startswith('```'):
|
|
return "code"
|
|
if text.startswith('#'):
|
|
return "heading"
|
|
if text.startswith('- ') or text.startswith('* '):
|
|
return "list"
|
|
return "paragraph"
|
|
```
|
|
|
|
## Client-Side Reassembly
|
|
|
|
### Web-klient med progressiv rendering
|
|
|
|
```typescript
|
|
// TypeScript: Client-side SSE consumption with chunk assembly
|
|
interface StreamChunk {
|
|
content: string;
|
|
isComplete: boolean;
|
|
tokenCount: number;
|
|
}
|
|
|
|
class AIResponseAssembler {
|
|
private chunks: string[] = [];
|
|
private onUpdate: (text: string) => void;
|
|
private onComplete: (text: string, stats: object) => void;
|
|
|
|
constructor(
|
|
onUpdate: (text: string) => void,
|
|
onComplete: (text: string, stats: object) => void
|
|
) {
|
|
this.onUpdate = onUpdate;
|
|
this.onComplete = onComplete;
|
|
}
|
|
|
|
async streamFromEndpoint(url: string, body: object): Promise<void> {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...body, stream: true }),
|
|
});
|
|
|
|
if (!response.body) throw new Error('No response body');
|
|
|
|
const reader = response.body
|
|
.pipeThrough(new TextDecoderStream())
|
|
.getReader();
|
|
|
|
let fullText = '';
|
|
let buffer = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += value;
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
const data = line.slice(6);
|
|
if (data === '[DONE]') {
|
|
this.onComplete(fullText, {
|
|
totalChunks: this.chunks.length,
|
|
totalLength: fullText.length
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
const token = parsed.choices?.[0]?.delta?.content || '';
|
|
if (token) {
|
|
fullText += token;
|
|
this.chunks.push(token);
|
|
this.onUpdate(fullText);
|
|
}
|
|
} catch { /* skip malformed */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Error Handling in Chunks
|
|
|
|
### Robust feilhåndtering for streaming
|
|
|
|
```python
|
|
class ResilientStreamProcessor:
|
|
"""Handle errors during streaming response."""
|
|
|
|
def __init__(self, client: AsyncAzureOpenAI, max_retries: int = 3):
|
|
self.client = client
|
|
self.max_retries = max_retries
|
|
|
|
async def stream_with_recovery(
|
|
self,
|
|
messages: list[dict],
|
|
model: str = "gpt-4o",
|
|
max_tokens: int = 2000
|
|
) -> dict:
|
|
"""Stream with automatic recovery on failure."""
|
|
accumulated = []
|
|
total_tokens_generated = 0
|
|
|
|
for attempt in range(self.max_retries):
|
|
try:
|
|
stream = await self.client.chat.completions.create(
|
|
model=model,
|
|
messages=messages,
|
|
stream=True,
|
|
max_tokens=max_tokens - total_tokens_generated
|
|
)
|
|
|
|
async for chunk in stream:
|
|
if chunk.choices and chunk.choices[0].delta.content:
|
|
token = chunk.choices[0].delta.content
|
|
accumulated.append(token)
|
|
total_tokens_generated += 1
|
|
|
|
# Sjekk for finish_reason
|
|
if chunk.choices and chunk.choices[0].finish_reason:
|
|
return {
|
|
"content": "".join(accumulated),
|
|
"finish_reason": chunk.choices[0].finish_reason,
|
|
"attempts": attempt + 1,
|
|
"recovered": attempt > 0
|
|
}
|
|
|
|
# Stream fullført uten finish_reason
|
|
return {
|
|
"content": "".join(accumulated),
|
|
"finish_reason": "stop",
|
|
"attempts": attempt + 1,
|
|
"recovered": attempt > 0
|
|
}
|
|
|
|
except Exception as e:
|
|
if attempt < self.max_retries - 1:
|
|
# Fortsett fra der vi stoppet
|
|
partial = "".join(accumulated)
|
|
if partial:
|
|
# Legg til partial output som assistant-melding
|
|
messages = messages + [
|
|
{"role": "assistant", "content": partial},
|
|
{"role": "user", "content": "Fortsett fra der du stoppet."}
|
|
]
|
|
await asyncio.sleep(2 ** attempt)
|
|
else:
|
|
return {
|
|
"content": "".join(accumulated),
|
|
"finish_reason": "error",
|
|
"error": str(e),
|
|
"attempts": attempt + 1
|
|
}
|
|
```
|
|
|
|
## Norsk offentlig sektor
|
|
|
|
- **Universell utforming**: Streaming gir bedre brukeropplevelse for skjermlesere og sakte nettverk — bruker ser innhold progressivt i stedet for å vente.
|
|
- **Saksbehandlingssystemer**: Chunk store AI-responser i semantiske blokker (overskrifter, avsnitt, tabeller) for enkel integrasjon i saksbehandlingsdokumenter.
|
|
- **Logging og audit**: Ved streaming, logg den komplette responsen etter fullføring for arkiverings- og revisjonskrav.
|
|
- **Application Gateway**: Konfigurer response buffer disabled for SSE-støtte gjennom Azure Application Gateway eller API Management.
|
|
|
|
## Beslutningsrammeverk
|
|
|
|
| Scenario | Anbefaling | Begrunnelse |
|
|
|----------|------------|-------------|
|
|
| Interaktiv chat UI | SSE streaming | Umiddelbar bruker-feedback |
|
|
| Batch dokumentprosessering | Ikke-streaming + semantic chunking | Enklere feilhåndtering |
|
|
| API-til-API integrasjon | Ikke-streaming | Enklere å parse komplett respons |
|
|
| Lang respons (>2000 tokens) | Streaming + chunk accumulator | Reduser opplevd ventetid |
|
|
| Kritisk pålitelighet | Streaming med recovery | Gjenoppta ved feil |
|
|
|
|
## Referanser
|
|
|
|
- [Azure OpenAI streaming](https://learn.microsoft.com/azure/foundry/openai/how-to/responses) — Streaming API
|
|
- [Server-Sent Events with Application Gateway](https://learn.microsoft.com/azure/application-gateway/use-server-sent-events) — SSE proxy
|
|
- [API Management SSE configuration](https://learn.microsoft.com/azure/api-management/how-to-server-sent-events) — APIM SSE
|
|
- [Server-Sent Events with App Gateway for Containers](https://learn.microsoft.com/azure/application-gateway/for-containers/server-sent-events) — Container SSE
|
|
|
|
## For arkitekten
|
|
|
|
- **Bruk denne referansen** når kunden implementerer streaming i AI-applikasjoner, trenger å chunke store responser, eller har feilhåndteringsproblemer med SSE.
|
|
- Streaming er alltid anbefalt for brukervendte applikasjoner — time-to-first-token reduseres fra sekunder til millisekunder.
|
|
- Konfigurer `stream_options: { include_usage: true }` for å få token-bruk i siste chunk — uten dette mangler kostnadssporing.
|
|
- Ved bruk av Application Gateway eller API Management som proxy: deaktiver response buffering for SSE-kompatibilitet.
|
|
- Implementer alltid recovery-logikk for streaming — nettverksavbrudd er uunngåelig i produksjon, og delvis generert output bør gjenbrukes.
|