Field-tested notes on robotics safety, ROS 2, embedded AI, AI agents, SaaS architecture and the practical systems that connect software to real-world outcomes.

Streaming LLM Output: Put DLP Before the First Byte

Server-side inspection holds a confidential response before releasing approved text to a browser

A support assistant streams a case summary into a browser. Halfway through the answer, a data loss prevention scanner detects an internal identifier and replaces the message with a warning. The interface now looks clean. The identifier has already crossed the boundary: it may be in the network response, browser memory, a screen recording or a client-side telemetry event.

For an output channel that must not receive uninspected confidential text, hold model output on the server until the applicable inspection and authorization checks finish. A streaming model API can feed that buffer; it does not require the application to stream raw text to its user. Delayed detection can stop further disclosure, but cannot recall bytes already delivered.

The engineering decision is the unit of release: a whole answer, an independently authorized record, or a prefix that a narrowly defined detector can prove ready. Choose that unit before choosing the animation in the chat window.

Put the boundary before every consumer

Consider an internal assistant whose retrieval service can read support records, but whose answer will be copied into a customer portal. Internal read permission does not establish permission to disclose every field to that destination. The classification and destination rules belong in the enterprise AI data-classification model.

For this example, use the entire answer as the release unit:

1
2
3
4
5
6
7
8
9
model stream -> protocol adapter -> bounded private buffer
|
complete response confirmed
|
destination policy + output inspection
|
approved immutable text -> renderer
-> client transport
-> allowed cache

There must be no parallel callback forwarding raw deltas to the browser. The same applies to tracing, analytics, notification previews and debugging logs. If raw output needs to be retained for investigation, that is a separate restricted evidence destination with its own access and retention policy.

A useful release condition is:

1
2
3
4
5
release only when:
response is complete
AND inspection approved this exact text
AND the recipient and destination are currently authorized
AND no failure or cancellation invalidated this attempt

“Complete” is an application-level fact. HTTP 200, a closed TCP connection, an exhausted iterator and a model’s length limit are not interchangeable success signals. The provider adapter must recognize the particular API’s normal completion event, errors, refusals, truncation and non-text outputs. Bind the approval to the immutable result and destination; do not scan one buffer and then send a subsequently modified one.

This is an implementation of an output-disclosure control in the enterprise agent threat model. It does not replace source access checks. Sensitive material that the model never receives is easier to protect than material that must be detected again in its answer.

Streaming modes have different promises

Provider-side moderation and application-side DLP can inspect different things. A harmful-content classifier does not automatically enforce a company’s customer-record disclosure policy. Ask what the configured policy examines, when it finishes, and which paths it excludes.

The documented vendor behaviors illustrate why “streaming with guardrails” is too vague a design requirement:

  • Microsoft’s content-streaming documentation distinguishes buffered filtering from asynchronous filtering, where tokens can arrive before their verdicts. The current page also warns about final-segment filtering behavior in a described custom-blocklist test. Treat that as a reason to test the terminal path on the exact deployment, not a universal measurement of all models.
  • Amazon Bedrock’s streaming guardrail documentation describes synchronous chunk buffering and asynchronous delivery. It explicitly says sensitive-information masking is unsupported in asynchronous mode.
  • Cloudflare AI Gateway’s DLP documentation says response DLP buffers the entire SSE response before inspection. That sacrifices incremental client delivery. Its documented inspection scope also excludes decoding base64 attachments or following external URLs.

These are documented behaviors, checked in November 2026, rather than benchmark results from this article. Verify API, configuration and model combinations in your own release tests.

Release unitWhat the application holdsSuitable useRemaining obligation
Whole answerAll text until normal completion and approvalConfidential summaries, external replies, answers with cross-sentence disclosure rulesBound size and wait time; test detector misses and alternate output paths
Independent recordOne complete typed record, plus any context the policy requiresA report with fields authorized and inspected independentlyProve that combining released records cannot violate a broader policy
Bounded prefixA suffix long enough for the detector’s specified future contextRestricted pattern languages with a demonstrated finite look-ahead requirementProve the bound, retain context, and inspect the final suffix
Immediate tokens with later interventionNo inspection buffer on the delivery pathChannels whose owners explicitly accept disclosure before verdictDetect and contain exposure; do not describe it as prevention

For the customer-portal example, whole-answer buffering is the simplest defensible starting point. It removes transport chunking from the inspection decision. It does not make the detector infallible.

A chunk is not a security boundary

Suppose the application blocks identifiers shaped like CASE-123456. A model stream might split one into CASE-12 and 3456. A detector applied independently to each fragment sees no complete match, while their concatenation contains the forbidden identifier.

Scanning the accumulated transcript after forwarding each fragment is also too late. Keeping the last 64 characters can help with some bounded patterns, but the number 64 proves nothing about an arbitrary detector. A rule that depends on whether a name and a business event appear anywhere in the same answer can require the entire answer. Sentence boundaries do not solve that dependency.

Even finite-pattern streaming requires care: the scanner must retain the context needed to detect a match that begins in an already inspected region and ends in the held suffix. It must withhold the sensitive portion until the decision is settled. A regex that permits arbitrarily long separators has no fixed maximum match length merely because its core identifier is short.

Transport decoding comes first. Decode UTF-8 incrementally, parse complete SSE or provider events, and extract the supported text fields before applying the release policy. A byte fragment can end inside a character. An SSE event can contain JSON whose escaped value differs from the wire representation. Define normalization deliberately and test it; do not make a detector work on raw JSON while a renderer displays decoded text.

A runnable release-boundary experiment

The following Python example models a whole-answer gate after protocol decoding. It accepts only text deltas followed by one terminal done event with the reason stop. Those are synthetic adapter events, not a provider SDK interface.

The scanner deliberately recognizes just one invented identifier pattern. The experiment checks when text can leave, not whether a production DLP system can recognize every secret. The caller supplies the already established destination context in a real implementation; authorization, deadlines and network transport are outside this small function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import re

IDENTIFIER = re.compile(r"CASE-[0-9]{6}")


def inspect(text):
return "deny" if IDENTIFIER.search(text) else "allow"


def release(events, scanner=inspect, max_bytes=4096):
parts, size, complete = [], 0, False
try:
for kind, value in events:
if complete:
return "invalid_tail", ""
if kind == "delta" and isinstance(value, str):
size += len(value.encode("utf-8"))
if size > max_bytes:
return "too_large", ""
parts.append(value)
elif kind == "done" and value == "stop":
complete = True
else:
return "invalid_event", ""
if not complete:
return "incomplete", ""
text = "".join(parts)
if scanner(text) != "allow":
return "blocked", ""
return "released", text
except Exception:
return "failed", ""


secret = "Case CASE-123456 ready."
for cut in range(len(secret) + 1):
events = [("delta", secret[:cut]), ("delta", secret[cut:]),
("done", "stop")]
assert release(events) == ("blocked", "")

assert release([("delta", "Ready."), ("done", "stop")]) == (
"released", "Ready.")
assert release([("delta", "Ready.")]) == ("incomplete", "")
assert release([("delta", "Ready."), ("done", "length")])[1] == ""
assert release([("delta", "éé"), ("done", "stop")],
max_bytes=3) == ("too_large", "")


def unavailable(_):
raise TimeoutError("synthetic scanner timeout")


assert release([("delta", "Ready."), ("done", "stop")],
scanner=unavailable) == ("failed", "")
print("23 split cases and 5 release/failure checks passed")

Running this example checks all 23 two-fragment partitions, including empty fragments at either end. Every partition produces the same blocked outcome. The five additional assertions cover successful release, a missing terminal event, truncation, a byte-size limit and a scanner exception. These are executed synthetic checks; there is no claim of measured provider latency or production detector accuracy.

Notice that the function returns no candidate text with an error. Production code should record a safe reason code and correlation ID rather than swallowing failures without observability. It also needs an actual scanner deadline: catching TimeoutError does not impose a timeout on a scanner that hangs. Likewise, cap individual incoming events and concurrent buffers before allocating them; the example’s total-size check alone is not an ingress memory limit.

A provider adapter may legitimately receive usage or annotation events after a content finish event. It must resolve all required inspection signals and map them into this simplified terminal contract. Do not feed arbitrary provider events directly into the example or discard late annotations to make it pass.

Make failure consume less authority

When inspection is unavailable, return a fixed status message such as “The response could not be released.” Do not stream the candidate under a temporary bypass. When the response exceeds the buffer limit, stop generation and offer a smaller request or another approved delivery mechanism. A shorter retry remains a new generation attempt that needs its own complete inspection.

Cancellation needs one owner. Once the user cancels or disconnects, invalidate that attempt’s release permission and request upstream cancellation. A late scan result must not trigger an email, persist an unreviewed answer or populate a shared cache. Reconnecting clients should receive only approved result objects; their stream offset is not proof of approval.

Redaction introduces another version of the content. If policy allows it, produce a new immutable redacted object, validate that object for its destination, and deliver only those bytes. Do not pass the original text alongside it for UI highlighting. URLs, filenames, citations and attachment previews need the same attention as the answer body. An approved prose channel is not permission to expose uninspected tool arguments or reasoning events.

Keep release evidence in the agent audit trail: attempt ID, recipient/destination scope, policy and detector versions, completion reason, inspected byte count, decision, release timestamp and restricted result reference. Ordinary logs should not contain rejected content. Where a content digest is useful for binding evidence, apply the organization’s privacy controls; a predictable identifier is not anonymized just because it was hashed.

Budget for the first approved byte

Whole-answer inspection changes the latency contract. In a sequential design:

1
2
3
4
first approved content latency
= complete generation time
+ final inspection time
+ authorization/release overhead

For an illustrative workload, assume 0.6 seconds to the first generated token, another 3.8 seconds to finish, 0.18 seconds for inspection and 0.02 seconds for release. The first approved content arrives at about 4.6 seconds. These are planning assumptions, not measurements. Reporting only the 0.6-second provider time would hide the user-visible behavior.

Show application-owned progress messages while waiting, provided they reveal no confidential context. Streaming a fixed “Preparing response” status is different from streaming model prose. Measure time to first approved content, completion latency, blocked-response rate, inspection failures and buffer occupancy separately.

Memory deserves a budget too. Two hundred simultaneous 64 KiB text buffers represent 12.5 MiB of payload before language-runtime overhead, parsed events and duplicate joined strings. Bound concurrency, response length and total wait time together. A slow scanner should cause backpressure or admission rejection, rather than an unbounded queue of confidential responses.

Before enabling the path, add these cases to the workflow’s release evaluation gate:

  1. Place the synthetic identifier at every split point and at the very end of the response. Check actual client network bytes, not just rendered text.
  2. End the upstream stream without normal completion, or with a length limit. Verify that nothing pending is released.
  3. Delay, fail and return an unknown verdict from the scanner. Verify the same closed release boundary.
  4. Disconnect and reconnect the client while inspection is pending. Check caches, notifications and replay handlers.
  5. Test Unicode fragmentation, escaped JSON, citations, tool events and oversized individual frames in the real protocol adapter.
  6. Inspect tracing and analytics sinks for rejected text, and test destination permission changes before release.

The acceptance criterion is precise: no candidate content crosses the configured destination boundary before the checks required for that release unit succeed. Detector effectiveness is a separate test suite with its own false-negative limits. Keeping those two claims separate makes the system easier to reason about—and makes a green chat bubble much less likely to conceal a disclosure that already happened.