অধ্যায় 3 · স্ট্রিমিং, কাঠামোবদ্ধ আউটপুট ও নির্ভরযোগ্য রেসপন্স
স্ট্রিমিং ও কাঠামোবদ্ধ ফলাফল
- পৃষ্ঠা 5 / 22
- 20 মিনিট পড়া
বগোতার মাতেও একটা হেল্প পেজে উত্তরের বক্স যোগ করছেন। পেজ যদি পুরো উত্তরের জন্য অপেক্ষা করে, গ্রাহক খালি বক্সের দিকে তাকিয়ে থাকেন। টেক্সট আসার সঙ্গে সঙ্গে দেখালে পেজ দ্রুত মনে হয়, কিন্তু তখন কোডকে সামলাতে হয় অর্ধেক উত্তর, কেটে যাওয়া সংযোগ, আর কিছু টেক্সট আসার পরের এরর। পরে একই বক্সকে অন্য কোডের জন্য কাঠামোবদ্ধ (structured) ডেটাও দিতে হয়, আর সেই ডেটা শুধু পার্স হয় বলেই বিশ্বাসযোগ্য নয়। এই পাতায় EvidenceDesk-এর দুই অংশই তৈরি হবে।
Pydantic আর FastAPI আপনি জানেন AI-এর জন্য Git, API ও ব্যাকএন্ড: বিগিনার থেকে, আর ক্লায়েন্ট জানেন প্রোভাইডার-নিরপেক্ষ LLM ক্লায়েন্ট থেকে।
যা শিখবেন
- স্ট্রিম আর চূড়ান্ত ফলাফলের পার্থক্য কী, এবং রেকর্ড করা ফিক্সচারের ওপর বানানো ফেক স্ট্রিম দেখতে কেমন।
- চাঙ্ক নিরাপদে জোড়া লাগানো: খালি, মাঝপথে থেমে যাওয়া আর ব্যর্থ স্ট্রিম, এবং বাংলা টেক্সট কেন অক্ষর ধরে ভাগ করতে হয়, বাইট ধরে নয়।
- ছোট একটা FastAPI এন্ডপয়েন্ট দিয়ে স্ট্রিম দেওয়া এবং সার্ভার ছাড়াই তা টেস্ট করা।
- Pydantic উত্তরের স্কিমা (schema) বানানো, মডেলের আউটপুট ভ্যালিডেট করা, আর সীমিত রিট্রাই বা নিরাপদ ব্যর্থতা দিয়ে সামলানো।
- বৈধ JSON কেন সঠিক উত্তর নয়, এবং পার্স করার আগে কেন স্টপ রিজন (stop reason) দেখতে হয়।
স্ট্রিমিং ও চূড়ান্ত ফলাফল
চূড়ান্ত ফলাফল মানে একটাই সম্পূর্ণ রেসপন্স। স্ট্রিম হলো ইভেন্টের ধারা: টেক্সটের অনেকগুলো আংশিক চাঙ্ক (chunk), তারপর একটা সমাপ্তি ইভেন্ট, যার সঙ্গে থাকে চূড়ান্ত মেটাডেটা (স্টপ রিজন, টোকেন সংখ্যা)। আমাদের কোডে প্রতিটা ইভেন্টের একটা kind আছে:
chunk chunk chunk ... done(stop_reason, usage) a good stream
chunk chunk interrupted: no done
chunk chunk error failed after partial text| চূড়ান্ত ফলাফল | স্ট্রিম | |
|---|---|---|
| আপনি পান | একটাই সম্পূর্ণ রেসপন্স | টুকরো টুকরো অংশ, তারপর একটা শেষের সংকেত |
| স্টপ রিজন ও usage | একই রেসপন্সে | শেষে, যদি আদৌ থাকে |
| ব্যর্থতা | পুরো কলই ব্যর্থ হয় | কিছু টেক্সট আসার পরেও আসতে পারে, তখন HTTP স্ট্যাটাস আগেই 200 |
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): OpenAI:
responses.create(..., stream=True);event.type == "response.output_text.delta"হলে টেক্সটের অংশevent.delta, শেষ হয়response.completedইভেন্টে, আরerrorইভেন্টে.messageথাকে। Anthropic:with client.messages.stream(...) as stream, তারপরstream.text_streamওstream.get_final_message(); শেষmessage_delta-তেstop_reasonও usage থাকে; স্ট্রিমের মাঝখানের এরর আসেerrorইভেন্ট হিসেবে, যদিও HTTP স্ট্যাটাস আগেই 200 হয়ে গেছে। Gemini:step.delta(টেক্সটevent.delta.text-এ),interaction.completedওerrorইভেন্ট। Ollama:chat(..., stream=True)একটা ইটারেটর দেয়, পড়তে হয়chunk['message']['content']; REST স্ট্রিম NDJSON, আর শেষ চাঙ্কেdone: trueথাকে।VERIFY: স্ট্রিমে usage কোথায় আসে (OpenAI, Gemini, Ollama) এবং স্ট্রিম আগে বন্ধ (cancel) করার উপায় নিশ্চিত করা যায়নি। Anthropic-এর ডকুমেন্টেশনে আছে
withব্লক ছাড়লে রেসপন্স বন্ধ হয়, কিন্তু সার্ভার তখনও কী বিল করে তা নেই। বর্তমান ডকুমেন্টেশন দেখে নিন।
ফেক স্ট্রিমিং ক্লায়েন্ট
ফেক স্ট্রিম FakeClient-এর (প্রোভাইডার-নিরপেক্ষ LLM ক্লায়েন্ট) ফিক্সচার থেকেই একটা রেকর্ড করা উত্তর নেয় এবং নির্দিষ্ট আকারের চাঙ্কে ভেঙে ফেরত দেয়, তাই প্রতিবার একই ফল আসে। সে সংযোগ কেটে যাওয়া (stop_after) আর এরর (fail_after) নকল করতে পারে। ফাইলটায় পুরো মডিউল আছে; পরের অংশগুলোতে তার বাকিটা কাজে লাগবে।
# file: evidencedesk/streaming.py
"""Streaming: events, a fake stream built on FakeClient, safe assembly, and a small SSE-style endpoint."""
from __future__ import annotations
import json
from collections.abc import Iterable, Iterator
from dataclasses import asdict, dataclass
from typing import Literal
from evidencedesk.llm import FakeClient, LLMRequest, Message, Usage
Kind = Literal["chunk", "done", "error"]
@dataclass(frozen=True)
class StreamEvent:
kind: Kind
text: str = "" # chunk: a piece of the answer; error: a short message; done: empty
usage: Usage | None = None # only on "done"
stop_reason: str | None = None # only on "done"
def chunk_text(text: str, size: int) -> list[str]:
"""Split by characters (code points), never by bytes."""
return [text[i:i + size] for i in range(0, len(text), size)]
class FakeStreamClient:
"""Replays a recorded answer as chunks. A stand-in: real streams arrive in sizes you do not control."""
def __init__(self, fixtures: dict[str, dict] | None = None, chunk_size: int = 12,
stop_after: int | None = None, fail_after: int | None = None) -> None:
self.inner = FakeClient(fixtures)
self.chunk_size = chunk_size
self.stop_after = stop_after # simulate a dropped connection: no "done" event
self.fail_after = fail_after # simulate an error after some chunks
def stream(self, request: LLMRequest) -> Iterator[StreamEvent]:
response = self.inner.complete(request) # raises FixtureMissing on the first next()
for n, piece in enumerate(chunk_text(response.text, self.chunk_size)):
if self.fail_after is not None and n == self.fail_after:
yield StreamEvent("error", "upstream failed")
return
if self.stop_after is not None and n == self.stop_after:
return
yield StreamEvent("chunk", piece)
yield StreamEvent("done", usage=response.usage, stop_reason=response.stop_reason)
@dataclass(frozen=True)
class Assembled:
text: str
status: Literal["complete", "empty", "interrupted", "error"]
usage: Usage | None = None
stop_reason: str | None = None
error: str | None = None
@property
def ok(self) -> bool:
return self.status == "complete"
def assemble(events: Iterable[StreamEvent]) -> Assembled:
"""Rebuild the answer. Only a stream that ends with `done` is complete."""
parts: list[str] = []
for event in events:
if event.kind == "chunk":
parts.append(event.text)
elif event.kind == "error":
return Assembled("".join(parts), "error", error=event.text)
elif event.kind == "done":
text = "".join(parts)
return Assembled(text, "complete" if text else "empty", event.usage, event.stop_reason)
else:
return Assembled("".join(parts), "error", error=f"unknown event kind {event.kind!r}")
text = "".join(parts)
return Assembled(text, "interrupted" if text else "empty")
def sse_lines(events: Iterable[StreamEvent]) -> Iterator[str]:
"""Server-sent-event style text: `event:` and `data:` lines, a blank line after each event."""
for event in events:
data = {"text": event.text}
if event.kind == "done":
data = {"stop_reason": event.stop_reason, "usage": asdict(event.usage or Usage())}
yield f"event: {event.kind}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def safe_events(client: FakeStreamClient, request: LLMRequest) -> Iterator[StreamEvent]:
"""Turn any failure inside the stream into one generic error event (the HTTP status is already 200)."""
try:
yield from client.stream(request)
except Exception:
yield StreamEvent("error", "upstream failed")
def make_app(client: FakeStreamClient):
from fastapi import FastAPI, Query
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.get("/ask/stream")
def ask_stream(q: str = Query(min_length=1, max_length=500)) -> StreamingResponse:
request = LLMRequest((Message("user", q),))
return StreamingResponse(sse_lines(safe_events(client, request)), media_type="text/event-stream")
return app
def parse_sse(lines: Iterable[str]) -> list[StreamEvent]:
"""Client side: read `event:` / `data:` lines back into events."""
events, kind = [], None
for line in lines:
if line.startswith("event: "):
kind = line[7:]
elif line.startswith("data: ") and kind:
data = json.loads(line[6:])
usage = Usage(**data["usage"]) if "usage" in data else None
events.append(StreamEvent(kind, data.get("text", ""), usage, data.get("stop_reason"))) # type: ignore[arg-type]
return eventsফেক চালিয়ে ইভেন্টগুলো দেখুন:
from evidencedesk.llm import LLMRequest, Message
from evidencedesk.streaming import FakeStreamClient
request = LLMRequest((Message("user", "How long does standard delivery take?"),))
for event in FakeStreamClient(chunk_size=24).stream(request):
print(event.kind, repr(event.text), event.stop_reason, event.usage)chunk 'Standard delivery takes ' None None
chunk '4 to 7 business days. Ex' None None
chunk 'press delivery takes 2 b' None None
chunk 'usiness days.' None None
done '' end Usage(input_tokens=38, output_tokens=20)চাঙ্ক শব্দের মাঝখানেই শেষ হয়েছে। আসল মডেলও যেখানে খুশি ভাঙে, তাই কখনো ধরে নেবেন না যে একটা চাঙ্ক মানে একটা শব্দ। মেটাডেটা আসে কেবল done-এর সঙ্গে।
ফেক স্ট্রিম একটা স্ট্যান্ড-ইন, যাতে টেস্ট বারবার একই ফল দেয়। সে মানুষের লেখা টেক্সট ফেরত দেয়। আসল মডেল কম অনুমানযোগ্য, আর ঠিক সে কারণেই টেস্ট করতে হয়।
চাঙ্ক নিরাপদে জোড়া লাগান
assemble উত্তর আবার গড়ে তোলে, আর শুধু done-এ শেষ হওয়া স্ট্রিমই complete গণ্য হয়। আংশিক টেক্সট লগের জন্য রাখা হয়, কিন্তু ok-এর মান মিথ্যা (false)।
from evidencedesk.streaming import FakeStreamClient, assemble
cases = {"normal": {}, "interrupted": {"stop_after": 2}, "error after partial": {"fail_after": 2}}
for name, options in cases.items():
result = assemble(FakeStreamClient(chunk_size=20, **options).stream(request))
print(f"{name:20} {result.status:12} ok={result.ok!s:5} {result.text!r}")
print(f"{'no events at all':20} {assemble([]).status}")normal complete ok=True 'Standard delivery takes 4 to 7 business days. Express delivery takes 2 business days.'
interrupted interrupted ok=False 'Standard delivery takes 4 to 7 business '
error after partial error ok=False 'Standard delivery takes 4 to 7 business '
no events at all emptyমাঝপথে থেমে যাওয়া স্ট্রিমই সবচেয়ে বিপজ্জনক: সে কোনো এরর ছাড়াই শেষ হয়, আর অর্ধেক উত্তরকে ছোট উত্তর বলে মনে হয়।
চাঙ্কের সীমানা ও বাংলা টেক্সট
UTF-8-এ একটা বাংলা অক্ষর তিন বাইটের। চাঙ্ক যদি অক্ষরের মাঝখানে শেষ হয়, তার বাইটগুলো একা ডিকোড করা যায় না। অক্ষর ধরে ভাগ করলে (chunk_text যেমন করে), জোড়া লাগানো টেক্সট সবসময় হুবহু মেলে:
from evidencedesk.streaming import chunk_text
text = "সাধারণ ডেলিভারিতে ৪ থেকে ৭ কার্যদিবস লাগে।"
raw = text.encode("utf-8")
print(len(text), "characters,", len(raw), "bytes")
by_bytes = [raw[i:i + 8] for i in range(0, len(raw), 8)]
try:
"".join(piece.decode("utf-8") for piece in by_bytes)
except UnicodeDecodeError as exc:
print("bytes:", exc)
damaged = "".join(piece.decode("utf-8", errors="replace") for piece in by_bytes)
print("bytes, errors=replace: damaged marks =", damaged.count("�"), "| same text:", damaged == text)
print("characters: same text:", "".join(chunk_text(text, 8)) == text)42 characters, 114 bytes
bytes: 'utf-8' codec can't decode bytes in position 6-7: unexpected end of data
bytes, errors=replace: damaged marks = 30 | same text: False
characters: same text: Trueআপনার হাতে যদি শুধু বাইট আসে, codecs-এর ইনক্রিমেন্টাল ডিকোডার ব্যবহার করুন। চাঙ্ক তবু একটা অক্ষর ও তার চিহ্নের মাঝখানে শেষ হতে পারে, স্ক্রিনে সেটা অদ্ভুত দেখায়, কিন্তু জোড়া লাগানো টেক্সট ঠিকই থাকে। Unicode সীমানা নিয়ে নির্ভরযোগ্য আউটপুট চুক্তি পাতায় আবার কথা হবে।
স্ট্রিমকে API-র মাধ্যমে পৌঁছে দিন
ব্রাউজার স্ট্রিম সবচেয়ে ভালো পড়ে সার্ভার-সেন্ট ইভেন্ট (server-sent events) হিসেবে: event: chunk আর data: {...} ধরনের লাইন, প্রতিটা ইভেন্টের পরে একটা ফাঁকা লাইন। sse_lines সেগুলো লেখে, make_app তাদের StreamingResponse-এ মোড়ায়, আর safe_events জেনারেটরের ভেতরের যেকোনো ব্যর্থতাকে একটা সাধারণ error ইভেন্টে বদলে দেয়, কারণ তখন স্ট্যাটাস কোড আগেই পাঠানো হয়ে গেছে। টেস্টে TestClient (আসল সার্ভার ছাড়া) ব্যবহার হয়; client.stream লাইনগুলো আসার সঙ্গে সঙ্গে পড়ে:
from fastapi.testclient import TestClient
from evidencedesk.streaming import FakeStreamClient, make_app
client = TestClient(make_app(FakeStreamClient(chunk_size=30)))
with client.stream("GET", "/ask/stream", params={"q": "How long does standard delivery take?"}) as response:
print(response.status_code, response.headers["content-type"])
for line in response.iter_lines():
print(line)
with client.stream("GET", "/ask/stream", params={"q": "Can I pay in instalments?"}) as response:
print(response.status_code, [line for line in response.iter_lines() if line])
print(client.get("/ask/stream", params={"q": ""}).status_code)200 text/event-stream; charset=utf-8
event: chunk
data: {"text": "Standard delivery takes 4 to 7"}
event: chunk
data: {"text": " business days. Express delive"}
event: chunk
data: {"text": "ry takes 2 business days."}
event: done
data: {"stop_reason": "end", "usage": {"input_tokens": 38, "output_tokens": 20}}
200 ['event: error', 'data: {"text": "upstream failed"}']
422প্রথমটা স্বাভাবিক স্ট্রিম; দ্বিতীয়টা স্ট্রিমের ভেতরের ব্যর্থতা (স্ট্যাটাস 200, একটাই error ইভেন্ট, অভ্যন্তরীণ কোনো তথ্য নেই); তৃতীয়টা খালি প্রশ্ন, যা 422 দিয়ে ফিরিয়ে দেওয়া হয়েছে। স্ট্রিম শুরুর আগে চলা চেকই কেবল স্ট্যাটাস কোড বদলাতে পারে।
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): ওই তারিখে PyPI-তে FastAPI 0.143.0 ও Starlette 1.7.0।
StreamingResponseএকটা async জেনারেটর বা সাধারণ জেনারেটর নেয়। FastAPI-এর ডকুমেন্টেশন বলে, একটা টাস্ক কেবলawait-এর জায়গায় বাতিল (cancel) হতে পারে, আর তাদের "Stream Data" পাতা (FastAPI 0.134.0 ও পরে) বাতিল হওয়া আপনার হয়ে সামলায়। FastAPI-তে বিল্ট-ইন সার্ভার-সেন্ট ইভেন্টও আছে (fastapi.sse.EventSourceResponse, 0.135.0 ও পরে); ফরম্যাট দেখানোর জন্য আমরা লাইনগুলো নিজে লিখি। Starlette স্ট্রিমিং রেসপন্সের জন্যawait request.is_disconnected()নথিভুক্ত করেছে; প্রতিটা এন্ডপয়েন্টে এটা লাগে কি না তা স্থির নয়। Starlette-এরTestClienthttpx2-এর ওপর তৈরি (FastAPI-এর টেস্টিং পাতা এখনোhttpxবলে), আর সে একই সঙ্গে রিকোয়েস্ট ও রেসপন্সের স্ট্রিমিং নকল করে না, তাই আসল ডিসকানেক্ট চলমান সার্ভারে টেস্ট করতে হবে; এখানে তা করা হয়নি। বর্তমান ডকুমেন্টেশন দেখে নিন।
ক্লায়েন্ট চলে গেলে নীতিটা এই: পড়া বন্ধ করুন, স্ট্রিম বন্ধ করুন, আর সার্ভারে কী থামে তা প্রোভাইডার SDK-র ডকুমেন্টেশনে দেখুন (VERIFY)। পরের পাতা নির্ভরযোগ্য আউটপুট চুক্তি বাতিল করা আর ইভেন্টের নকশা নিয়ে আবার কথা বলবে।
Pydantic রেসপন্স স্কিমা বানান
অন্য কোড যখন উত্তর ব্যবহার করে, "কিছু টেক্সট" যথেষ্ট নয়। AnswerResult প্রয়োজনীয় ফিল্ড, তাদের টাইপ আর অনুমোদিত মান ঠিক করে: confidence ও status হলো Literal, অজানা কি (key) নিষিদ্ধ, আর একটা ছোট ভ্যালিডেটর status-কে cited_ids-এর সঙ্গে বাঁধে। মডিউলের বাকি অংশ ভ্যালিডেট করে, রিট্রাই করে আর বিজনেস রুল দেখে; পরের অংশগুলো একে টুকরো টুকরো করে ব্যবহার করবে।
# file: evidencedesk/results.py
"""Structured results: a Pydantic schema, validation that never trusts the model, a bounded retry, a business-rule check."""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from evidencedesk.llm import LLMClient, LLMRequest, LLMResponse
CORPUS = Path(__file__).resolve().parent.parent / "corpus"
SORRY = "Sorry, I could not produce a reliable answer. Please try again or ask a person."
class AnswerResult(BaseModel):
model_config = ConfigDict(extra="forbid") # unknown keys are an error, not ignored
answer: str = Field(min_length=1, max_length=1000)
cited_ids: list[str]
confidence: Literal["high", "medium", "low"]
status: Literal["answered", "abstained"]
@model_validator(mode="after")
def citations_match_status(self):
if self.status == "answered" and not self.cited_ids:
raise ValueError("an answered result needs at least one cited id")
if self.status == "abstained" and self.cited_ids:
raise ValueError("an abstained result must not cite anything")
return self
@dataclass(frozen=True)
class Outcome:
ok: bool
code: str # "ok", "truncated", "unusable_stop", "invalid_json", "invalid_schema"
value: AnswerResult | None = None
problems: tuple[dict, ...] = () # only `loc` and `msg`, never the input or the Pydantic url
attempts: int = 1
@property
def user_message(self) -> str:
return self.value.answer if self.value else SORRY
def parse_answer(response: LLMResponse) -> Outcome:
"""Stop reason first, then JSON syntax, then schema. Never raises on model output."""
if response.stop_reason == "length":
return Outcome(False, "truncated")
if response.stop_reason != "end":
return Outcome(False, "unusable_stop")
try:
value = AnswerResult.model_validate_json(response.text)
except ValidationError as exc:
problems = tuple({"loc": ".".join(str(p) for p in e["loc"]), "msg": e["msg"]} for e in exc.errors())
code = "invalid_json" if exc.errors()[0]["type"] == "json_invalid" else "invalid_schema"
return Outcome(False, code, problems=problems)
return Outcome(True, "ok", value)
RETRYABLE = {"invalid_json", "invalid_schema"}
def get_answer(client: LLMClient, request: LLMRequest, max_attempts: int = 2) -> Outcome:
"""At most `max_attempts` calls. A cut-off answer is not retried: the same request would be cut off again."""
if max_attempts < 1:
raise ValueError("max_attempts must be at least 1")
outcome = Outcome(False, "invalid_json")
for attempt in range(1, max_attempts + 1):
outcome = parse_answer(client.complete(request))
if outcome.ok or outcome.code not in RETRYABLE:
break
return Outcome(outcome.ok, outcome.code, outcome.value, outcome.problems, attempt)
class SequenceClient:
"""Test double: returns the given responses in order (a bad one first, a good one second)."""
def __init__(self, responses: list[LLMResponse]) -> None:
self.responses = list(responses)
self.calls = 0
def complete(self, request: LLMRequest) -> LLMResponse:
self.calls += 1
return self.responses[min(self.calls, len(self.responses)) - 1]
def load_docs(folder: Path = CORPUS) -> dict[str, dict]:
"""Corpus documents by id: {"status": ..., "text": ...}. Reads the small front matter only."""
docs = {}
for path in sorted(folder.glob("*.md")):
head, _, body = path.read_text(encoding="utf-8").removeprefix("---\n").partition("\n---\n")
meta = dict(line.split(": ", 1) for line in head.splitlines() if ": " in line)
docs[meta["id"]] = {"status": meta["status"], "text": body}
return docs
def business_problems(result: AnswerResult, docs: dict[str, dict]) -> list[str]:
"""Rules a schema cannot express. A weak check, not a proof: it catches only what it looks for."""
problems = []
for doc_id in result.cited_ids:
if doc_id not in docs:
problems.append(f"unknown id {doc_id}")
elif docs[doc_id]["status"] != "current":
problems.append(f"{doc_id} is {docs[doc_id]['status']}")
sources = " ".join(docs[i]["text"] for i in result.cited_ids if i in docs)
for number in sorted(set(re.findall(r"\d+", result.answer))):
if re.search(rf"\b{number}\b", sources) is None:
problems.append(f"number {number} not in the cited text")
return problemsমডেলের আউটপুট ভ্যালিডেট করুন
মডেলের আউটপুট অবিশ্বস্ত ইনপুট, ফর্মের ফিল্ডের মতো। খারাপ আউটপুটে parse_answer কখনো এক্সেপশন ছোড়ে না: সে একটা Outcome ফেরত দেয় একটা কোডসহ, আর স্কিমার ভুলের ক্ষেত্রে শুধু loc ও msg (ইনপুট নয়, Pydantic URL-ও নয়)। ছয়টা উত্তর চেষ্টা করুন:
import json
from evidencedesk.llm import LLMResponse, Usage
from evidencedesk.results import parse_answer
def reply(text, stop="end"):
return LLMResponse(text, "fake-model", Usage(10, 10), stop)
good = {"answer": "You can return a new, unridden bike within 30 days of delivery.",
"cited_ids": ["kc-returns-v3"], "confidence": "high", "status": "answered"}
cases = {
"good": json.dumps(good),
"prose instead of JSON": "Sure! The answer is 30 days.",
"broken JSON": json.dumps(good)[:40],
"bad value, extra key": json.dumps({**good, "confidence": "certain", "note": "hi"}),
"missing field": json.dumps({k: v for k, v in good.items() if k != "status"}),
"answered, no citation": json.dumps({**good, "cited_ids": []}),
}
for name, text in cases.items():
outcome = parse_answer(reply(text))
print(f"{name}: {outcome.code}")
for problem in outcome.problems:
print(" ", problem["loc"] or "(whole)", "|", problem["msg"])good: ok
prose instead of JSON: invalid_json
(whole) | Invalid JSON: expected value at line 1 column 1
broken JSON: invalid_json
(whole) | Invalid JSON: EOF while parsing a string at line 1 column 40
bad value, extra key: invalid_schema
note | Extra inputs are not permitted
confidence | Input should be 'high', 'medium' or 'low'
missing field: invalid_schema
status | Field required
answered, no citation: invalid_schema
(whole) | Value error, an answered result needs at least one cited idপ্রতিটা ব্যর্থতা একইভাবে ফিরিয়ে দেওয়া হয়েছে, নির্দিষ্ট জায়গা আর বার্তাসহ। অজানা কি (key) থাকলেও ব্যর্থ হয় (extra="forbid"), কারণ কোনো কি চুপচাপ উপেক্ষা করলে বোঝা যায় না যে মডেল সরে গেছে।
অবৈধ আউটপুট থেকে সেরে উঠুন
পার্স ব্যর্থ হওয়া একবারের গোলমালও হতে পারে, তাই get_answer রিট্রাই করে, কিন্তু সর্বোচ্চ max_attempts বার, তারপর ফেরত দেয় একটা নিরাপদ ব্যর্থতা: ok=False এবং ব্যবহারকারীর জন্য একটা নির্দিষ্ট ক্ষমা চাওয়ার বার্তা। প্রতিটা চেষ্টায় টাকা আর সময় লাগে। এখানে SequenceClient এমন একটা মডেলের ভূমিকা নেয়, যে প্রথমে ভুল আর পরে ঠিক উত্তর দেয়:
from evidencedesk.results import SequenceClient, get_answer
bad = reply("Sure! Here is the answer you asked for.")
scripts = {"bad, then good": [bad, reply(json.dumps(good))], "always bad": [bad]}
for name, script in scripts.items():
client = SequenceClient(script)
outcome = get_answer(client, request, max_attempts=3)
print(f"{name}: ok={outcome.ok} code={outcome.code} attempts={outcome.attempts} calls={client.calls}")
print(" user sees:", outcome.user_message)bad, then good: ok=True code=ok attempts=2 calls=2
user sees: You can return a new, unridden bike within 30 days of delivery.
always bad: ok=False code=invalid_json attempts=3 calls=3
user sees: Sorry, I could not produce a reliable answer. Please try again or ask a person.শুধু অবৈধ JSON আর অবৈধ স্কিমা রিট্রাই হয়, আর ব্যবহারকারীর বার্তায় ভাঙা টেক্সট কখনো থাকে না। কেটে যাওয়া উত্তর রিট্রাই হয় না: একই রিকোয়েস্টে আবারও কাটা পড়বে।
বৈধ মানেই সঠিক নয়
ভ্যালিডেশন গড়ন প্রমাণ করে, সত্য নয়। মডেল নিখুঁত JSON দিতে পারে, যা একটা সত্যিকারের ডকুমেন্টের উল্লেখ করেও ভুল তথ্য বলে। business_problems এমন তিনটা নিয়ম যোগ করে, যা স্কিমা বলতে পারে না: উদ্ধৃত আইডি থাকতে হবে, বাতিল (superseded) হওয়া যাবে না, আর উত্তরের প্রতিটা সংখ্যা উদ্ধৃত টেক্সটে থাকতে হবে। Kestrel Cycles-এর কর্পাসের বিপরীতে (পুরোনো রিটার্ন নীতিতে ১৪ দিন, বর্তমানটায় বাইকের জন্য ৩০):
from evidencedesk.results import AnswerResult, business_problems, load_docs
docs = load_docs()
claims = {
"correct": ("A new bike can be returned within 30 days.", "kc-returns-v3"),
"invented number": ("A new bike can be returned within 45 days.", "kc-returns-v3"),
"superseded source": ("A new bike can be returned within 14 days.", "kc-returns-v2"),
"wrong, passes all checks": ("A new bike can be returned within 14 days.", "kc-returns-v3"),
}
for name, (answer, doc_id) in claims.items():
result = AnswerResult(answer=answer, cited_ids=[doc_id], confidence="high", status="answered")
print(f"{name:25} schema ok | business problems: {business_problems(result, docs)}")correct schema ok | business problems: []
invented number schema ok | business problems: ['number 45 not in the cited text']
superseded source schema ok | business problems: ['kc-returns-v2 is superseded']
wrong, passes all checks schema ok | business problems: []প্রথম তিনটা ধরা পড়েছে। শেষটা পড়েনি: "১৪ দিন" বর্তমান নীতিতেও আছে, অ্যাক্সেসরিজের জন্য, তাই সব চেক পাস করে এবং দাবিটা তবু ভুল। এই দুর্বল চেক একটা ন্যূনতম স্তর, প্রমাণ নয়: আসল সমর্থনের জন্য উদ্ধৃত টেক্সটের সঙ্গে দাবি মেলাতে হয়, আর ইভ্যাল সেট মাপে কতবার উত্তর ভুল হয়।
কেটে যাওয়া উত্তর সামলান
মডেল আউটপুটের সীমায় পৌঁছালে উত্তর হঠাৎ থেমে যায়। আগে স্টপ রিজন দেখুন: "length" মানে রেসপন্স ব্যবহারযোগ্য নয়, টেক্সট যেমনই দেখাক। আগে পার্স করলে কেটে যাওয়া JSON অবজেক্ট একটা বিভ্রান্তিকর সিনট্যাক্স এরর হয়ে দেখা দেয়, আর আপনার রিট্রাই লুপ এমন সমস্যায় কল নষ্ট করে, যা সে সারাতে পারে না।
from evidencedesk.results import AnswerResult
whole = json.dumps(good)
cut = reply(whole[:70], "length")
print("stop reason first:", parse_answer(cut).code)
try:
AnswerResult.model_validate_json(cut.text)
except ValueError as exc:
print("parse first :", exc.errors()[0]["type"])
print("complete text, stop=length:", parse_answer(reply(whole, "length")).code)stop reason first: truncated
parse first : json_invalid
complete text, stop=length: truncatedপ্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): কাঠামোবদ্ধ আউটপুট চাওয়ার পদ্ধতি প্রতি প্রোভাইডারে আলাদা: OpenAI ব্যবহার করে
text.format-এtype: "json_schema"ওstrict: true(বা Pydantic মডেলসহresponses.parse); Anthropic ব্যবহার করেoutput_config.format-এ একটা JSON স্কিমা (বাmessages.parse); Gemini-র Interactions API নেয় স্কিমাসহresponse_format; Ollama নেয়formatপ্যারামিটার। পার্সের আগে কী দেখবেন: OpenAI-তেrefusalধরনের কনটেন্ট পার্ট এবংstatus == "incomplete"সঙ্গে কারণmax_output_tokens; Anthropic-এstop_reasonযদিrefusalবাmax_tokensহয়; Gemini-তেSTOPছাড়া অন্যfinishReason(Interactions-এর সমতুল্যটা নিশ্চিত নয়); Ollama-তেdone_reason(এর মানগুলো নিশ্চিত নয়)। প্রতিটা প্রোভাইডার JSON Schema-র শুধু একটা উপসেট সমর্থন করে, আর Gemini-র ডকুমেন্টেশন বলে মানগুলো তবু আপনার নিজের ভ্যালিডেশন লাগে। বর্তমান ডকুমেন্টেশন দেখে নিন।
টেস্ট চালান
দুই মডিউলেরই টেস্ট আছে tests/test_streaming.py ও tests/test_results.py-এ (কিটে; ওপরের প্রতিটা কেস সেখানে অ্যাসার্শন হিসেবে আছে, সঙ্গে অনেক রকম চাঙ্ক আকারে বাংলা টেক্সট):
python -m pytest -q tests/test_streaming.py tests/test_results.py | sed 's/ in [0-9.]*s//'............................... [100%]
31 passedএটাই পাস লাইন; sed সেকেন্ড সরিয়ে দিয়েছে। এই টেস্ট দেখাতে পারে না আসল স্ট্রিম টেক্সট কীভাবে ভাঙে, বা আসল মডেল কতবার খারাপ JSON দেয়; তারা দেখায় যে আপনার কোড দুটোই সামলায়।
কাজের জায়গায় এটা কোথায় দেখা যায়
প্রতিটা চ্যাট-ধরনের প্রোডাক্ট স্ট্রিম করে, আর মডেলের আউটপুট অন্য কোডে পাঠানো প্রতিটা ইন্টিগ্রেশন তা ভ্যালিডেট করে। কায়রোর ওমর এমন ফিচার রিভিউ করেন এবং তিনটা প্রশ্ন করেন: স্ট্রিম মাঝপথে ভাঙলে ব্যবহারকারী কী দেখে, দ্বিতীয় খারাপ JSON-এর পরে কী হয়, আর কোন চেক সঠিক-গড়নের ভুল উত্তর ধরে?
সাধারণ ভুল
- শেষ হওয়া স্ট্রিমকে সম্পূর্ণ উত্তর ধরা।
doneছাড়া সেটা কেবল একটা প্রিফিক্স। স্ট্যাটাস দেখুন, আর আংশিক টেক্সট ব্যবহারকারীর উত্তরের বাইরে রাখুন। - বাইট ধরে ভাগ বা ডিকোড করা। একাধিক বাইটের অক্ষর ভেঙে যায়।
strনিয়ে কাজ করুন, বা ইনক্রিমেন্টাল ডিকোডার। - স্টপ রিজন দেখার আগে পার্স করা। কেটে যাওয়া উত্তরকে সিনট্যাক্স সমস্যা মনে হয় আর বৃথাই রিট্রাই হয়।
- সীমাহীন রিট্রাই, বা সবকিছু রিট্রাই করা। সীমা দিন; যা আরেকবার চেষ্টায় সারে শুধু তা-ই রিট্রাই করুন। ব্যবহারকারীকে কখনো কাঁচা ভ্যালিডেশন এরর দেখাবেন না।
- "ভ্যালিডেট হয়েছে" মানে "ঠিক" ভাবা। বিজনেস রুল যোগ করুন, তারপর ইভ্যাল দিয়ে মাপুন।
নিজে চেষ্টা করুন
- সহজ। আগে অনুমান করুন, তারপর চালান:
chunk_size=20আর একটা চাঙ্কের পরে থেমে যাওয়া স্ট্রিমের স্ট্যাটাস ও টেক্সট কী হবে? - মাঝারি। প্রোভাইডার-নিরপেক্ষ LLM ক্লায়েন্ট আবার দেখুন: তার ব্যাটারির প্রশ্নের ফিক্সচার স্টপ রিজন
lengthনিয়ে আগেই থেমে যায়। সেটা স্ট্রিম করুন।assembleফলাফলেokকি না দেখুন। উত্তর ব্যবহারযোগ্য কি না তা বলে দেয় এমন একটা নিয়ম লিখুন। - কঠিন। টাস্কের ফলাফল স্ট্রিম ও ভ্যালিডেট করার অনুশীলন: এমন একটা pytest ফাইল লিখুন, যা একটা JSON উত্তর চাঙ্কে স্ট্রিম করে, জোড়া লাগায় এবং
parse_answerদিয়ে ভ্যালিডেট করে; টেস্ট থাকবে ভালো স্ট্রিম, মাঝপথে থামা স্ট্রিম, আংশিক টেক্সটের পরে এরর, খারাপ JSON আর কেটে যাওয়া স্ট্রিমের জন্য।
উত্তর
সহজ। ২০ অক্ষরের একটা চাঙ্ক এসেছে, তারপর আর কিছু নয়: স্ট্যাটাস interrupted।
from evidencedesk.llm import LLMRequest, Message
from evidencedesk.streaming import FakeStreamClient, assemble
request = LLMRequest((Message("user", "How long does standard delivery take?"),))
result = assemble(FakeStreamClient(chunk_size=20, stop_after=1).stream(request))
print(result.status, repr(result.text))interrupted 'Standard delivery ta'মাঝারি। স্ট্রিম স্বাভাবিকভাবে শেষ হয়েছে, তাই ok সত্য, কিন্তু স্টপ রিজন বলছে মডেল মাঝপথে কাটা পড়েছে। "স্ট্রিম সম্পূর্ণ" আর "উত্তর সম্পূর্ণ" আলাদা প্রশ্ন।
battery = LLMRequest((Message("user", "How should I store the e-bike battery for a long time?"),))
result = assemble(FakeStreamClient(chunk_size=30).stream(battery))
print(result.status, result.ok, result.stop_reason)
print("usable:", result.ok and result.stop_reason == "end")complete True length
usable: Falseকঠিন। হেল্পার ok নয় এমন স্ট্রিম কখনো পার্স করে না, আর স্টপ রিজন পার্সারে পাঠিয়ে দেয়।
# file: tests/test_p05_exercise.py
import json
from evidencedesk.llm import LLMRequest, LLMResponse, Message
from evidencedesk.results import parse_answer
from evidencedesk.streaming import FakeStreamClient, assemble
Q = "Which returns document is old?"
PAYLOAD = {"answer": "kc-returns-v2 is the old returns policy.", "cited_ids": ["kc-returns-v2"],
"confidence": "medium", "status": "answered"}
def task_result(text, stop="end", **options):
fixtures = {Q: {"text": text, "usage": {"input_tokens": 12, "output_tokens": 30}, "stop_reason": stop}}
streamed = assemble(FakeStreamClient(fixtures, chunk_size=9, **options).stream(LLMRequest((Message("user", Q),))))
if not streamed.ok:
return streamed.status # interrupted or error: never parse a partial stream
return parse_answer(LLMResponse(streamed.text, "fake-model", streamed.usage, streamed.stop_reason))
def test_complete_stream_gives_a_valid_result():
outcome = task_result(json.dumps(PAYLOAD))
assert outcome.ok and outcome.value.cited_ids == ["kc-returns-v2"]
def test_interrupted_stream_is_never_parsed():
assert task_result(json.dumps(PAYLOAD), stop_after=2) == "interrupted"
def test_error_after_partial_text_is_never_parsed():
assert task_result(json.dumps(PAYLOAD), fail_after=2) == "error"
def test_complete_stream_with_bad_json_is_rejected():
assert task_result("The old one is kc-returns-v2.").code == "invalid_json"
def test_cut_off_stream_is_truncated_not_invalid_json():
assert task_result(json.dumps(PAYLOAD)[:30], stop="length").code == "truncated"python -m pytest -q tests/test_p05_exercise.py | sed 's/ in [0-9.]*s//'..... [100%]
5 passedপাস চেকলিস্ট:
- সহজ: স্ট্যাটাস
interruptedএবং প্রথম ২০ অক্ষর। - মাঝারি:
okসত্য, স্টপ রিজনlength, আর আপনার নিয়মusable: Falseছাপে। - কঠিন: পাঁচটা টেস্ট পাস করে; কোনো টেস্টে নেটওয়ার্ক লাগে না; আংশিক স্ট্রিম কখনো পার্সারে যায় না; কেটে যাওয়া কেসে
invalid_jsonনয়,truncatedআসে।
সারসংক্ষেপ
- স্ট্রিম মানে চাঙ্ক আর মেটাডেটাসহ একটা শেষের ইভেন্ট। শুধু
done-এ শেষ হওয়া স্ট্রিমই সম্পূর্ণ; মাঝপথে থামা আর ব্যর্থ স্ট্রিম আলাদা অবস্থা। - রেকর্ড করা চাঙ্ক ফেরত দেওয়া ফেক দিয়ে স্ট্রিমিং অফলাইনে টেস্ট করুন; টেক্সট অক্ষর ধরে ভাগ করুন, বাইট ধরে নয়।
- স্ট্রিমকে সার্ভার-সেন্ট ইভেন্টের লাইন হিসেবে দিন,
TestClientদিয়ে টেস্ট করুন, আর ভেতরের ব্যর্থতাকেerrorইভেন্টে বদলান। - Pydantic স্কিমা প্রয়োজনীয় ফিল্ড, টাইপ ও অনুমোদিত মান বলে দেয়। মডেলের প্রতিটা আউটপুট ভ্যালিডেট করুন, শুধু
locওmsgজানান, আর সীমিত রিট্রাই বা নিরাপদ ব্যর্থতা দিয়ে সামলান। - বৈধ JSON মানেই সঠিক উত্তর নয়: বিজনেস রুল যোগ করুন, আর পার্সের আগে স্টপ রিজন দেখুন।
এরপর: নির্ভরযোগ্য আউটপুট চুক্তি যোগ করে স্কিমার সংস্করণ, খালি, আংশিক ও এরর অবস্থার অনুমানযোগ্য রূপ, বাতিল ও ডিসকানেক্ট, আকার ও URL-এর চেক, প্রোভাইডার তুলনা আর রিগ্রেশন ফিক্সচার।