Heuristically, see https://stackoverflow.com/questions/12213445/identifying-kernel-threads
ps and top (from procps-3.28) use "no command line" as the check, and have no knowledge of PF_KTHREAD. That is what triggers them to add [``] to the process name, it's not part of the actual process name. Since the command line is under control of the process itself, it's possible that it's been modified.
It depends on how far back (2.4? 2.2?) you need to go. A quick rummage on a few systems indicates no evident common flag (field 9 of /proc/PID/stat)
On late 2.6.x you can mostly guess by the the parent PID being 0 for [kthreadd], that often will have PID 2, and all threads will be its children (init may have PPID=0 too). Or possibly (early 2.6.x?) a [kthread] instead, but not PID 2, but it may only be the parent of most, not all threads.
Another way is to inspect /proc/PID/maps (user space memory map), for a kernel thread this will be empty -- so as long the process flag (in /proc/PID/stat) is not &(PF_EXITING), /proc/PID/maps being empty is a good indicator. Note you must actually read maps, not just stat() it, since the size is misreported as 0; and you must also have permission, if you do not it may appear empty without causing an error.
for pp in /proc/[0-9]*; do if [ -z "$(< $pp/maps)" ]; then echo ${pp##/proc/}; fi; done
You could add a similar expression to check that cmdline is also empty.