Flink-runtime showcase — composes guardrails + inference + a scorer over Flink keyed state. The guardrail/inference/scorer pieces are portable SPIs; this walkthrough wires them on the Flink runtime. For the runs-everywhere baseline see the banking agent on every runtime.
Source:
src/main/java/org/agentic/flink/example/triage/SupportTriageExample.javaInline README:src/main/java/org/agentic/flink/example/triage/README.md
This walkthrough is the long-form companion to the inline README. It explains why each stage is there and what the design alternatives were.
Customer-support triage has three pressures pulling in different directions:
- Latency — replies should land in seconds, not after a 30-second LLM call.
- Cost — calling a 70B model on every ticket is wasteful when 80% of tickets fall into four well-known intents.
- Tone consistency — replies have to sound like your company, not the foundation model's default voice.
The example threads all three: tiny models do the deterministic work
(sentiment + intent + reranking), the LLM only writes prose, and a tone-pass
through LangChain4J's two-arg generate enforces voice.
Ticket
│
▼ 1. ClassifierGuardrail — sentiment
│ • SST-2 DistilBERT, ~50 ms CPU
│ • Label NEGATIVE → route to human queue, skip LLM
│
▼ 2. InferenceToolAdapter — intent
│ • BART-MNLI zero-shot classifier
│ • Returns {label: refund, score: 0.81, probabilities: {...}}
│ • Result wrapped as ToolExecutor; LLM "called" it through the tool path
│
▼ 3. Draft loop — 3 candidates at temperature 0.85
│ • SPI-only: ChatConnection + ChatSetup
│ • Vendor-neutral; switch to OpenAI by changing the connection
│
▼ 4. Scorer — cross-encoder rerank
│ • Cross-encoder/ms-marco-MiniLM-L-6-v2
│ • Pair-scores (candidate, ticket-body); pick the best
│
▼ 5. Tone rewrite — LangChain4J escape hatch
│ • Downcast to LangChain4jChatClient
│ • Use LC4j's generate(system, user) convenience the SPI doesn't expose
│
▼ Final reply
The blunt rule "if the customer sounds furious, escalate to a human" prevents the LLM from drafting a tone-deaf reply to a complaint. The DJL classifier gives us this for ~50 ms of CPU instead of an extra LLM call.
Intent is an input to the LLM's draft prompt, not a gate. We want the LLM to
see "this is a refund ticket" so it can structure the reply accordingly. The
InferenceToolAdapter is the right wrapper because the LLM could in principle
re-call it on a follow-up message.
A single sample from a 3B-parameter model at temperature 0.85 is noisy. Three samples + reranker gives roughly best-of-3 with linear cost. The reranker is cheap (~30 ms each on CPU) so the total is dominated by the LLM calls.
The framework SPI exposes chat(List<ChatMessage>, ChatSetup) → ChatResponse.
That's deliberately generic. LangChain4J's ChatLanguageModel.generate(system, user) is a one-shot convenience for prompts that don't need a multi-message
chat history. Rather than push that into our ChatClient (where it would
constrain every backend), the escape hatch lets one specific call do the
LangChain4J-idiomatic thing.
See the inline README for the full command + prerequisites. The first run downloads the three HuggingFace models (~250 MB total) into DJL's cache; the second run is cache-warm and finishes in a few seconds.
The example processes one synthetic ticket. To run it on a real DataStream,
follow the pattern in ContentModerationExample — extract the per-ticket
logic into a KeyedProcessFunction, bind the chat / inference clients once in
open(), and emit Reply records on the main output (with route-to-human
on a side output).